This commit is contained in:
2026-08-21 10:43:25 +08:00
parent d5e9ba6d4c
commit e17f03e596
55 changed files with 775 additions and 173 deletions
+2 -2
View File
@@ -4,9 +4,9 @@ baiyue:
cell: ${DEPLOYMENT_CELL:prod-overseas}
data-residency: ${DATA_RESIDENCY:OVERSEAS}
security:
issuer: ${JWT_ISSUER:https://auth.example.com}
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot}
openapi.enabled: ${SWAGGER_ENABLED:false}
@@ -4,6 +4,9 @@ spring:
username: ${LASTLEG_DB_USERNAME}
password: ${LASTLEG_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
lastleg:
dispatch-query-api:
enabled: true
baiyue:
clients:
domestic-warehouse:
+12
View File
@@ -0,0 +1,12 @@
# Auth signs short-lived overseas RS256 tokens with a private JWK Set.
OVERSEAS_JWT_ENABLED=true
OVERSEAS_JWT_ISSUER=auth-service
OVERSEAS_JWT_AUDIENCE=lastleg-api
OVERSEAS_JWT_CURRENT_KID=replace-with-current-signing-kid
OVERSEAS_ACCESS_TOKEN_TTL=15m
OVERSEAS_JWT_RSA_JWK_SET={"keys":[{"kty":"RSA","kid":"replace-with-current-signing-kid","use":"sig","alg":"RS256","n":"replace-with-modulus","e":"AQAB","d":"replace-with-private-exponent","p":"replace","q":"replace","dp":"replace","dq":"replace","qi":"replace"}]}
# Overseas services validate tokens against Auth's public JWKS endpoint.
JWT_JWK_SET_URI=https://replace-with-auth-host/api/auth/.well-known/jwks.json
JWT_ISSUER=auth-service
JWT_AUDIENCE=lastleg-api
@@ -199,6 +199,21 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id'));
PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='sub_order_status'),
'SELECT 1',
'ALTER TABLE ll_order ADD COLUMN sub_order_status VARCHAR(255) NULL COMMENT ''子单据状态汇总'' AFTER current_status'));
PREPARE phase4_sub_order_status_stmt FROM @ddl; EXECUTE phase4_sub_order_status_stmt; DEALLOCATE PREPARE phase4_sub_order_status_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='order_type' AND COALESCE(column_comment,'') LIKE '订单类型:%'),
'SELECT 1',
'ALTER TABLE ll_order MODIFY COLUMN order_type VARCHAR(32) NOT NULL DEFAULT ''FIRST_LEG'' COMMENT ''订单类型:FIRST_LEG头程订单、RELABEL_APPLICATION换标下单、WAREHOUSE_TRANSFER仓储转运、LOCAL_LOOSEBOARD本土散板、RETURN_RELABEL退货换标、ONE_CLICK_DROPSHIP一件代发'''));
PREPARE phase4_order_type_comment_stmt FROM @ddl; EXECUTE phase4_order_type_comment_stmt; DEALLOCATE PREPARE phase4_order_type_comment_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='current_status' AND COALESCE(column_comment,'') LIKE '当前业务状态:%'),
'SELECT 1',
'ALTER TABLE ll_order MODIFY COLUMN current_status VARCHAR(32) NOT NULL DEFAULT ''PENDING_ASSIGNMENT'' COMMENT ''当前业务状态:PENDING_ASSIGNMENT待分配、PENDING_INBOUND待入库、INBOUND_COMPLETED入库完成、PENDING_DELIVERY待派送、DELIVERING派送中、DELIVERED派送完成'''));
PREPARE phase4_order_status_comment_stmt FROM @ddl; EXECUTE phase4_order_status_comment_stmt; DEALLOCATE PREPARE phase4_order_status_comment_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_file_ids'),
'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表''',
'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表'' AFTER completed_at'));
@@ -0,0 +1,28 @@
package com.baiyue.gateway.config;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class OverseasLastlegRouteContractTest {
@Test
void platformDispatchPathIsForwardedToLastlegWithoutPrefixRewrite() throws IOException {
try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) {
assertThat(input).isNotNull();
String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
int routeStart = yaml.indexOf("- id: lastleg-service");
int routeEnd = yaml.indexOf("- id: warehouse-overseas-service", routeStart);
assertThat(routeStart).isGreaterThanOrEqualTo(0);
assertThat(routeEnd).isGreaterThan(routeStart);
String route = yaml.substring(routeStart, routeEnd);
assertThat(route).contains("uri: lb://lastleg-service")
.contains("/api/platform/v1/last-leg/**")
.doesNotContain("StripPrefix", "RewritePath");
}
}
}
+2 -1
View File
@@ -39,7 +39,7 @@
- 四期在本服务内只进入尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购和本土卡车;本土散板新增实现也归本服务的独立稳定子域。
- `dispatch/`、`delivery/`、出库单、PDA 出库拣货、派送和其他库存离仓能力虽然已存在于服务代码或表结构中,但不属于当前四期开发与验收范围,不得因代码已存在而自动纳入。
- 历史 `dispatch/`、`delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。
- 历史 `dispatch/` 写操作、PDA 出库和 `delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。平台排车列表与详情是独立只读入口,仅由海外配置 `lastleg.dispatch-query-api.enabled=true` 启用,不得借此开放预排、编辑、取消、PDA 出库或派送。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。
- 海外仓库、库位、实物托盘和资源锁归 `warehouse-overseas-service/`;浏览器 API 统一经 `gateway-overseas/`,本服务不得复制仓储资源主数据或另建公网认证入口。
## 数据库表索引与注释约束
@@ -73,6 +73,7 @@
## 查询与租户约束
- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。
- 尾程平台只读查询接口的 `customerId` 为可选筛选条件:平台身份省略时查询全部客户数据,传入时按指定客户过滤;商户身份忽略客户端传入值并始终按 Token 中的自身客户隔离。创建、编辑、审核等需要明确目标客户的写接口仍必须传入 `customerId`。
- 常规入仓库存余额以客户、海外仓、目的仓、入仓单和 SKU 为唯一业务维度;PDA 完成入库时按冻结商品明细精确拆分 SKU 箱数,转仓收货继承来源库存 SKU,不得回退为仅按入仓单汇总。
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
@@ -56,7 +56,7 @@ public class LastlegOrderController {
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid LastlegOrderRequests.Query query) {
return ApiResponse.success(orderService.page(scope(user, customerId), query));
return ApiResponse.success(orderService.page(platformScope(user, customerId), query));
}
@GetMapping("/api/platform/v1/last-leg/orders/{id}")
@@ -66,7 +66,7 @@ public class LastlegOrderController {
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long id) {
return ApiResponse.success(orderService.detail(scope(user, customerId), id));
return ApiResponse.success(orderService.detail(platformScope(user, customerId), id));
}
@GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv")
@@ -76,7 +76,7 @@ public class LastlegOrderController {
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid LastlegOrderRequests.Query query) {
byte[] body = orderService.exportCsv(scope(user, customerId), query);
byte[] body = orderService.exportCsv(platformScope(user, customerId), query);
return ResponseEntity.ok()
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv")
@@ -91,7 +91,7 @@ public class LastlegOrderController {
@RequestParam(required = false) String customerId,
@PathVariable long id,
@Valid @RequestBody LastlegOrderRequests.SingleTransferDestination request) {
return ApiResponse.success(orderService.transferDestination(scope(user, customerId), request.withOrderId(id)));
return ApiResponse.success(orderService.transferDestination(platformScope(user, customerId), request.withOrderId(id)));
}
@PutMapping("/api/platform/v1/last-leg/orders/destination")
@@ -101,20 +101,17 @@ public class LastlegOrderController {
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid @RequestBody LastlegOrderRequests.TransferDestination request) {
return ApiResponse.success(orderService.transferDestination(scope(user, customerId), request));
return ApiResponse.success(orderService.transferDestination(platformScope(user, customerId), request));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
private String platformScope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform queries must specify a customer");
}
return requestedCustomer;
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required");
}
return String.valueOf(user.tenantId());
return requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)
? null : requestedCustomer.trim();
}
}
@@ -0,0 +1,48 @@
package com.baiyue.lastleg.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.dto.LastlegOrderDetailResponse;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.service.LastlegOrderService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
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/orders")
public class MerchantLastlegOrderController {
@Resource private LastlegOrderService orderService;
@GetMapping
public ApiResponse<PageResult<LastlegOrderSummary>> list(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid LastlegOrderRequests.Query query) {
return ApiResponse.success(orderService.page(customer(user), query));
}
@GetMapping("/{id}")
public ApiResponse<LastlegOrderDetailResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long id) {
return ApiResponse.success(orderService.detail(customer(user), id));
}
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());
}
}
@@ -1,7 +1,6 @@
package com.baiyue.lastleg.dispatch.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.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
@@ -61,24 +60,6 @@ public class DispatchController {
return ApiResponse.success(service.preArrange(scopedTenant, operatorId, request));
}
@GetMapping
@Operation(summary = "Page dispatch orders")
public ApiResponse<PageResult<DispatchResponse>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid DispatchRequests.Query query) {
return ApiResponse.success(service.page(scope(user, customerId), query));
}
@GetMapping("/{dispatchNo}")
@Operation(summary = "Get dispatch details and all pallet destinations")
public ApiResponse<DispatchResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable String dispatchNo) {
return ApiResponse.success(service.detail(scope(user, customerId), dispatchNo));
}
@PutMapping("/{dispatchNo}")
@Operation(summary = "Edit vehicle, driver, appointment, route and departure data")
public ApiResponse<DispatchResponse> update(
@@ -0,0 +1,55 @@
package com.baiyue.lastleg.dispatch.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.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import com.baiyue.lastleg.dispatch.service.DispatchService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.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
@ConditionalOnProperty(name = "lastleg.dispatch-query-api.enabled", havingValue = "true")
@RequestMapping("/api/platform/v1/last-leg/dispatches")
@Tag(name = "Tail-leg vehicle dispatch query")
public class DispatchQueryController {
@Resource private DispatchService service;
@GetMapping
@Operation(summary = "Page dispatch orders")
public ApiResponse<PageResult<DispatchResponse>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid DispatchRequests.Query query) {
requirePlatform(user);
return ApiResponse.success(service.page(null, query));
}
@GetMapping("/{dispatchNo}")
@Operation(summary = "Get dispatch details and all pallet destinations")
public ApiResponse<DispatchResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo) {
requirePlatform(user);
return ApiResponse.success(service.detail(null, dispatchNo));
}
private void requirePlatform(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, "Platform application required");
}
}
}
@@ -11,6 +11,9 @@ public interface DispatchOrderMapper extends BaseMapper<DispatchOrder> {
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND dispatch_no=#{dispatchNo} LIMIT 1")
DispatchOrder findByDispatchNo(@Param("customerId") String customerId, @Param("dispatchNo") String dispatchNo);
@Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} LIMIT 1")
DispatchOrder findAnyByDispatchNo(@Param("dispatchNo") String dispatchNo);
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
DispatchOrder lockById(@Param("customerId") String customerId, @Param("id") Long id);
}
@@ -112,11 +112,11 @@ public class DispatchPdaWorkflowService {
long picked = picks.totalItem(customerId, task.getId(), itemId);
if (picked + request.packages() > item.getPackageCount()) throw conflict("Picked packages exceed dispatch pallet packages");
String keyBase = "DISPATCH:" + order.getDispatchNo() + ":" + itemId + ":" + request.idempotencyKey();
InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(customerId, request.positionId(),
InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(request.positionId(),
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":ALLOCATE", request.remark());
inventory.allocate(customerId, operatorId, move);
inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(customerId, request.positionId(),
inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(),
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":PICK", request.remark()));
DispatchPdaPick pick = new DispatchPdaPick();
@@ -200,7 +200,7 @@ public class DispatchPdaWorkflowService {
long shipped = 0;
for (DispatchPdaPick pick : dispatchPicks) {
String key = "DISPATCH:" + order.getDispatchNo() + ":PICK:" + pick.getId() + ":SHIP";
inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(customerId,
inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(
pick.getInventoryPositionId(), n(pick.getPackages()),
outbound.getOutboundNo(), outbound.getOutboundNo(), null, key, request.remark()));
shipped += n(pick.getPackages());
@@ -150,7 +150,7 @@ public class DispatchService {
public PageResult<DispatchResponse> page(String customerId, DispatchRequests.Query query) {
Page<DispatchOrder> page = orders.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<DispatchOrder>().eq(DispatchOrder::getCustomerId, customerId)
new LambdaQueryWrapper<DispatchOrder>().eq(StringUtils.hasText(customerId), DispatchOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo())
.like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate())
.eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status())
@@ -158,13 +158,13 @@ public class DispatchService {
query.fbaAppointmentNo())
.orderByDesc(DispatchOrder::getCreatedAt));
List<DispatchResponse> records = page.getRecords().stream()
.map(order -> response(order, items.findByDispatchOrder(customerId, order.getId()))).toList();
.map(order -> response(order, items.findByDispatchOrder(order.getCustomerId(), order.getId()))).toList();
return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
public DispatchResponse detail(String customerId, String dispatchNo) {
DispatchOrder dispatch = find(customerId, dispatchNo);
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
}
@Transactional
@@ -216,7 +216,9 @@ public class DispatchService {
}
private DispatchOrder find(String customerId, String dispatchNo) {
DispatchOrder dispatch = orders.findByDispatchNo(customerId, dispatchNo);
DispatchOrder dispatch = StringUtils.hasText(customerId)
? orders.findByDispatchNo(customerId, dispatchNo)
: orders.findAnyByDispatchNo(dispatchNo);
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
return dispatch;
}
@@ -13,6 +13,7 @@ public record LastlegOrderSummary(
String sourceInboundId,
String inboundCode,
String sourceOrderNo,
String customerId,
String customerName,
String overseasWarehouseId,
String overseasWarehouseName,
@@ -37,7 +37,7 @@ public class InboundPlanController {
@Operation(summary = "分页查询入库订单", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
public ApiResponse<PageResult<InboundPlanListResponse>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId,
@RequestParam(required = false) String customerId,
@Valid InboundPlanRequests.Query query) {
return ApiResponse.success(service.page(scope(user, customerId), query));
}
@@ -111,10 +111,9 @@ public class InboundPlanController {
}
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform request must specify target tenant");
return null;
}
return requestedCustomer;
return requestedCustomer.trim();
}
return String.valueOf(user.tenantId());
}
@@ -11,14 +11,15 @@ import java.util.List;
@Mapper
public interface InboundPlanQueryMapper {
String FROM_AND_FILTER = " FROM ll_inbound_plan_line l" +
" JOIN ll_inbound_plan p ON p.id=l.plan_id AND p.customer_id=#{customerId}" +
" JOIN ll_order o ON o.id=l.order_id AND o.customer_id=#{customerId}" +
" JOIN ll_inbound_plan p ON p.id=l.plan_id" +
" JOIN ll_order o ON o.id=l.order_id" +
"<if test='customerId != null and customerId != \"\"'> AND p.customer_id=#{customerId} AND o.customer_id=#{customerId}</if>" +
" LEFT JOIN ll_inbound_fba_appointment_item ai ON ai.line_id=l.id" +
" AND ai.id=(SELECT ai2.id FROM ll_inbound_fba_appointment_item ai2" +
" JOIN ll_inbound_fba_appointment a2 ON a2.id=ai2.appointment_id" +
" WHERE ai2.line_id=l.id AND a2.customer_id=#{customerId}" +
" WHERE ai2.line_id=l.id <if test='customerId != null and customerId != \"\"'> AND a2.customer_id=#{customerId}</if>" +
" ORDER BY a2.created_at DESC,ai2.id DESC LIMIT 1)" +
" LEFT JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id AND a.customer_id=#{customerId}" +
" LEFT JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id <if test='customerId != null and customerId != \"\"'> AND a.customer_id=#{customerId}</if>" +
" WHERE 1=1" +
"<if test='q.planNo != null and q.planNo != \"\"'> AND p.plan_no LIKE CONCAT('%',#{q.planNo},'%')</if>" +
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
@@ -29,8 +30,8 @@ public interface InboundPlanQueryMapper {
"<if test='q.fbaWarehouseCode != null and q.fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code LIKE CONCAT('%',#{q.fbaWarehouseCode},'%')</if>" +
"<choose><when test='q.appointmentStatus == \"UNAPPOINTED\"'> AND ai.id IS NULL</when>" +
"<when test='q.appointmentStatus != null and q.appointmentStatus != \"\"'> AND a.appointment_status=#{q.appointmentStatus}</when></choose>" +
"<choose><when test='q.dispatchStatus == \"SCHEDULED\"'> AND EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.customer_id=#{customerId} AND di.order_id=l.order_id)</when>" +
"<when test='q.dispatchStatus == \"UNSCHEDULED\"'> AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.customer_id=#{customerId} AND di.order_id=l.order_id)</when></choose>" +
"<choose><when test='q.dispatchStatus == \"SCHEDULED\"'> AND EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.order_id=l.order_id <if test='customerId != null and customerId != \"\"'> AND di.customer_id=#{customerId}</if>)</when>" +
"<when test='q.dispatchStatus == \"UNSCHEDULED\"'> AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.order_id=l.order_id <if test='customerId != null and customerId != \"\"'> AND di.customer_id=#{customerId}</if>)</when></choose>" +
"<if test='q.createdFrom != null'> AND p.created_at &gt;=#{q.createdFrom}</if>" +
"<if test='q.createdTo != null'> AND p.created_at &lt;=#{q.createdTo}</if>";
@@ -156,9 +156,10 @@ public class InboundFbaAppointmentService {
}
private InboundFbaAppointment find(String customerId, String appointmentNo) {
return appointmentMapper.selectOne(new LambdaQueryWrapper<InboundFbaAppointment>()
.eq(InboundFbaAppointment::getCustomerId, customerId)
.eq(InboundFbaAppointment::getAppointmentNo, appointmentNo));
LambdaQueryWrapper<InboundFbaAppointment> wrapper = new LambdaQueryWrapper<InboundFbaAppointment>()
.eq(InboundFbaAppointment::getAppointmentNo, appointmentNo);
if (customerId != null) wrapper.eq(InboundFbaAppointment::getCustomerId, customerId);
return appointmentMapper.selectOne(wrapper);
}
private List<InboundFbaAppointmentItem> items(Long appointmentId) {
@@ -117,8 +117,10 @@ public class InboundPlanService {
@Transactional(readOnly = true)
public InboundPlanResponse detailByPlanNo(String customerId, String planNo) {
InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo));
LambdaQueryWrapper<InboundPlan> wrapper = new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getPlanNo, planNo);
if (customerId != null) wrapper.eq(InboundPlan::getCustomerId, customerId);
InboundPlan plan = planMapper.selectOne(wrapper);
if (plan == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
}
@@ -244,7 +246,7 @@ public class InboundPlanService {
}
private InboundPlanResponse detail(String customerId, InboundPlan plan) {
if (!customerId.equals(plan.getCustomerId())) {
if (customerId != null && !customerId.equals(plan.getCustomerId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
}
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
@@ -33,7 +33,8 @@ public class InventoryCommandController {
public ApiResponse<InventoryCommandResponse> hold(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION);
return ApiResponse.success(service.hold(scope(user, request.customerId()), operator(user), request));
requirePlatform(user);
return ApiResponse.success(service.holdPlatform(operator(user), request));
}
@PostMapping("/release")
@@ -42,7 +43,8 @@ public class InventoryCommandController {
public ApiResponse<InventoryCommandResponse> release(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION);
return ApiResponse.success(service.release(scope(user, request.customerId()), operator(user), request));
requirePlatform(user);
return ApiResponse.success(service.releasePlatform(operator(user), request));
}
@PostMapping("/adjust")
@@ -51,19 +53,15 @@ public class InventoryCommandController {
public ApiResponse<InventoryCommandResponse> adjust(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Adjustment request) {
requirePermission(user, ADJUST_PERMISSION);
return ApiResponse.success(service.adjust(scope(user, request.customerId()), operator(user), request));
requirePlatform(user);
return ApiResponse.success(service.adjustPlatform(operator(user), request));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
private void requirePlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform request must specify target tenant");
}
return requestedCustomer.trim();
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required");
}
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) {
@@ -39,7 +39,6 @@ public final class InventoryCommandRequests {
}
public record Move(
String customerId,
@NotNull Long positionId,
@Positive long packages,
String businessOrderNo,
@@ -51,7 +50,6 @@ public final class InventoryCommandRequests {
}
public record Adjustment(
String customerId,
@NotNull Long positionId,
long packageDelta,
@NotBlank String reason,
@@ -8,6 +8,9 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface InventoryCommandMapper {
@Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} LIMIT 1")
InventoryPosition findPosition(@Param("positionId") Long positionId);
@Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND id=#{positionId} LIMIT 1 FOR UPDATE")
InventoryPosition lockPosition(@Param("customerId") String customerId,
@Param("positionId") Long positionId);
@@ -67,11 +67,21 @@ public class InventoryCommandService {
return move(customerId, operatorId, "HOLD", Bucket.AVAILABLE, Bucket.HOLD, request);
}
@Transactional
public InventoryCommandResponse holdPlatform(String operatorId, InventoryCommandRequests.Move request) {
return move(customerForPosition(request.positionId()), operatorId, "HOLD", Bucket.AVAILABLE, Bucket.HOLD, request);
}
@Transactional
public InventoryCommandResponse release(String customerId, String operatorId, InventoryCommandRequests.Move request) {
return move(customerId, operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request);
}
@Transactional
public InventoryCommandResponse releasePlatform(String operatorId, InventoryCommandRequests.Move request) {
return move(customerForPosition(request.positionId()), operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request);
}
@Transactional
public InventoryCommandResponse allocate(String customerId, String operatorId, InventoryCommandRequests.Move request) {
return move(customerId, operatorId, "ALLOCATE", Bucket.AVAILABLE, Bucket.ALLOCATED, request);
@@ -147,6 +157,11 @@ public class InventoryCommandService {
});
}
@Transactional
public InventoryCommandResponse adjustPlatform(String operatorId, InventoryCommandRequests.Adjustment request) {
return adjust(customerForPosition(request.positionId()), operatorId, request);
}
private InventoryCommandResponse move(String customerId, String operatorId, String operationType,
Bucket from, Bucket to, InventoryCommandRequests.Move request) {
return execute(customerId, operatorId, operationType, request.idempotencyKey(), request, operation -> {
@@ -255,6 +270,12 @@ public class InventoryCommandService {
return new Locked(balance, position);
}
private String customerForPosition(Long positionId) {
InventoryPosition position = commands.findPosition(positionId);
if (position == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory position not found");
return position.getCustomerId();
}
private InventoryCommandResponse execute(String customerId, String operatorId, String operationType,
String key, Object request, Work work) {
String normalizedKey = required(key, "Idempotency key is required");
@@ -22,7 +22,6 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@@ -41,9 +40,9 @@ public class InventoryQueryController {
@Operation(summary = "Get inbound-order package inventory totals", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<InventoryDashboardResponse> summary(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.BalanceQuery query) {
return ApiResponse.success(service.dashboard(scope(user, customerId), query));
requirePlatform(user);
return ApiResponse.success(service.dashboard(null, query));
}
@GetMapping(BASE_PATH + "/balances")
@@ -51,9 +50,9 @@ public class InventoryQueryController {
@Operation(summary = "Page inventory by customer, warehouse and inbound order", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<PageResult<InventoryBalanceView>> balances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.BalanceQuery query) {
return ApiResponse.success(service.balances(scope(user, customerId), query));
requirePlatform(user);
return ApiResponse.success(service.balances(null, query));
}
@GetMapping(BASE_PATH + "/balances/{balanceId}/positions")
@@ -61,10 +60,10 @@ public class InventoryQueryController {
@Operation(summary = "List pallet and batch positions for one inbound-order balance", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<List<InventoryPositionView>> positions(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long balanceId,
@Valid InventoryQueryRequests.PositionQuery query) {
return ApiResponse.success(service.positions(scope(user, customerId), balanceId, query));
requirePlatform(user);
return ApiResponse.success(service.positions(null, balanceId, query));
}
@GetMapping(BASE_PATH + "/ledger")
@@ -72,9 +71,9 @@ public class InventoryQueryController {
@Operation(summary = "Page append-only inventory movements", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<PageResult<InventoryLedgerView>> ledgers(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.LedgerQuery query) {
return ApiResponse.success(service.ledgers(scope(user, customerId), query));
requirePlatform(user);
return ApiResponse.success(service.ledgers(null, query));
}
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
@@ -82,9 +81,9 @@ public class InventoryQueryController {
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
public ResponseEntity<byte[]> exportBalances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.BalanceQuery query) {
return csv(service.exportBalances(scope(user, customerId), query), "inventory-balances.csv");
requirePlatform(user);
return csv(service.exportBalances(null, query), "inventory-balances.csv");
}
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv")
@@ -92,9 +91,9 @@ public class InventoryQueryController {
@Operation(summary = "Export filtered inventory movements as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
public ResponseEntity<byte[]> exportLedgers(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.LedgerQuery query) {
return csv(service.exportLedgers(scope(user, customerId), query), "inventory-ledger.csv");
requirePlatform(user);
return csv(service.exportLedgers(null, query), "inventory-ledger.csv");
}
private static ResponseEntity<byte[]> csv(byte[] body, String filename) {
@@ -104,17 +103,12 @@ public class InventoryQueryController {
.body(body);
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
private void requirePlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform queries must specify a customer");
}
return requestedCustomer;
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required");
}
return String.valueOf(user.tenantId());
}
}
@@ -11,18 +11,19 @@ import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface InventoryQueryMapper {
String BALANCE_FILTER = " WHERE customer_id=#{customerId}" +
String BALANCE_FILTER = " WHERE 1=1" +
"<if test='customerId != null and customerId != \"\"'> AND customer_id=#{customerId}</if>" +
"<if test='q.customerName != null and q.customerName != \"\"'> AND customer_name LIKE CONCAT('%',#{q.customerName},'%')</if>" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
"<if test='q.overseasWarehouseCode != null and q.overseasWarehouseCode != \"\"'> AND overseas_warehouse_code=#{q.overseasWarehouseCode}</if>" +
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
"<if test='q.inboundOrderNo != null and q.inboundOrderNo != \"\"'> AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')</if>" +
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=ll_inventory_balance.id AND l.customer_id=#{customerId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" +
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=ll_inventory_balance.id AND l.customer_id=ll_inventory_balance.customer_id AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" +
"<if test='q.skuCode != null and q.skuCode != \"\"'> AND sku_code LIKE CONCAT('%',#{q.skuCode},'%')</if>" +
"<if test='q.productName != null and q.productName != \"\"'> AND (product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR product_name_en LIKE CONCAT('%',#{q.productName},'%'))</if>" +
"<if test='q.batchNo != null and q.batchNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=#{customerId} AND p.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))</if>" +
"<if test='q.warehouseLocationCode != null and q.warehouseLocationCode != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=#{customerId} AND p.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))</if>" +
"<if test='q.batchNo != null and q.batchNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=ll_inventory_balance.customer_id AND p.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))</if>" +
"<if test='q.warehouseLocationCode != null and q.warehouseLocationCode != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=ll_inventory_balance.customer_id AND p.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))</if>" +
"<choose>" +
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND available_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages &gt; 0</when>" +
@@ -33,18 +34,19 @@ public interface InventoryQueryMapper {
"<when test='q.inventoryStatus == \"PROCUREMENT\"'> AND procurement_packages &gt; 0</when>" +
"</choose>";
String JOINED_BALANCE_FILTER = " WHERE b.customer_id=#{customerId}" +
String JOINED_BALANCE_FILTER = " WHERE 1=1" +
"<if test='customerId != null and customerId != \"\"'> AND b.customer_id=#{customerId}</if>" +
"<if test='q.customerName != null and q.customerName != \"\"'> AND b.customer_name LIKE CONCAT('%',#{q.customerName},'%')</if>" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND b.overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
"<if test='q.overseasWarehouseCode != null and q.overseasWarehouseCode != \"\"'> AND b.overseas_warehouse_code=#{q.overseasWarehouseCode}</if>" +
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND b.destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
"<if test='q.inboundOrderNo != null and q.inboundOrderNo != \"\"'> AND b.inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')</if>" +
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND b.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=b.id AND l.customer_id=#{customerId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" +
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=b.id AND l.customer_id=b.customer_id AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" +
"<if test='q.skuCode != null and q.skuCode != \"\"'> AND b.sku_code LIKE CONCAT('%',#{q.skuCode},'%')</if>" +
"<if test='q.productName != null and q.productName != \"\"'> AND (b.product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR b.product_name_en LIKE CONCAT('%',#{q.productName},'%'))</if>" +
"<if test='q.batchNo != null and q.batchNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=#{customerId} AND p2.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))</if>" +
"<if test='q.warehouseLocationCode != null and q.warehouseLocationCode != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=#{customerId} AND p2.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))</if>" +
"<if test='q.batchNo != null and q.batchNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=b.customer_id AND p2.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))</if>" +
"<if test='q.warehouseLocationCode != null and q.warehouseLocationCode != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=b.customer_id AND p2.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))</if>" +
"<choose>" +
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND b.available_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND b.hold_packages &gt; 0</when>" +
@@ -103,7 +105,11 @@ public interface InventoryQueryMapper {
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} LIMIT 1")
InventoryBalance ownedBalance(@Param("customerId") String customerId, @Param("balanceId") long balanceId);
String LEDGER_FILTER = " WHERE customer_id=#{customerId}" +
@Select("SELECT * FROM ll_inventory_balance WHERE id=#{balanceId} LIMIT 1")
InventoryBalance findBalance(@Param("balanceId") long balanceId);
String LEDGER_FILTER = " WHERE 1=1" +
"<if test='customerId != null and customerId != \"\"'> AND customer_id=#{customerId}</if>" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
"<if test='q.businessType != null and q.businessType != \"\"'> AND business_type=#{q.businessType}</if>" +
@@ -59,11 +59,15 @@ public class InventoryQueryService {
public List<InventoryPositionView> positions(String customerId, long balanceId,
InventoryQueryRequests.PositionQuery query) {
validateStatus(query.inventoryStatus(), POSITION_STATUSES);
InventoryBalance balance = mapper.ownedBalance(customerId, balanceId);
InventoryBalance balance = customerId == null
? mapper.findBalance(balanceId)
: mapper.ownedBalance(customerId, balanceId);
if (balance == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory balance not found");
}
return mapper.positions(customerId, balanceId, query).stream().map(value -> positionView(value, balance)).toList();
String recordCustomerId = customerId == null ? balance.getCustomerId() : customerId;
return mapper.positions(recordCustomerId, balanceId, query).stream()
.map(value -> positionView(value, balance)).toList();
}
public PageResult<InventoryLedgerView> ledgers(String customerId, InventoryQueryRequests.LedgerQuery query) {
@@ -0,0 +1,10 @@
package com.baiyue.lastleg.masterdata.exchange;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
/** 注册尾程主数据域的所有 Mapper。 */
@Configuration
@MapperScan("com.baiyue.lastleg.masterdata")
public class ExchangeMapperConfiguration {
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.model;
/** Standard main-process statuses for a last-leg order. */
public enum LastlegOrderStatus {
PENDING_ASSIGNMENT("待分配"),
PENDING_INBOUND("待入库"),
INBOUND_COMPLETED("入库完成"),
PENDING_DELIVERY("待派送"),
DELIVERING("派送中"),
DELIVERED("派送完成");
private final String label;
LastlegOrderStatus(String label) {
this.label = label;
}
public String label() {
return label;
}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.model;
/** Supported business types for a last-leg order. */
public enum LastlegOrderType {
/** Overseas order created from a completed first-leg loading plan. */
FIRST_LEG("头程订单"),
/** Customer-initiated relabel order. */
RELABEL_APPLICATION("换标下单"),
/** Warehouse-to-warehouse transfer order. */
WAREHOUSE_TRANSFER("仓储转运"),
/** Domestic/local loose pallet order. */
LOCAL_LOOSEBOARD("本土散板"),
/** Return shipment that also requires relabeling. */
RETURN_RELABEL("退货换标"),
/** One-click dropship order, retained for existing data and workflows. */
ONE_CLICK_DROPSHIP("一件代发");
private final String label;
LastlegOrderType(String label) {
this.label = label;
}
public String label() {
return label;
}
}
@@ -25,40 +25,40 @@ public class RelabelApplicationController {
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_CREATE + "')")
@Operation(summary = "Create a relabel application", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_CREATE)
public ApiResponse<RelabelResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid @RequestBody RelabelRequests.Create request) {
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request));
@Valid @RequestBody RelabelRequests.Create request) {
requirePlatform(user);
return ApiResponse.success(service.create("0", operator(user), request));
}
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
@Operation(summary = "Page relabel applications", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_LIST)
public ApiResponse<PageResult<RelabelResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid RelabelRequests.Query query) {
return ApiResponse.success(service.page(scope(user, customerId), query));
@Valid RelabelRequests.Query query) {
requirePlatform(user);
return ApiResponse.success(service.page(null, query));
}
@GetMapping("/{relabelNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
@Operation(summary = "Get a relabel application", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_LIST)
public ApiResponse<RelabelResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String relabelNo) {
return ApiResponse.success(service.detail(scope(user, customerId), relabelNo));
@PathVariable String relabelNo) {
requirePlatform(user);
return ApiResponse.success(service.detail(null, relabelNo));
}
@PutMapping("/{relabelNo}/status")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')")
@Operation(summary = "Change relabel application status", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_STATUS)
public ApiResponse<RelabelResponses.Detail> status(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String relabelNo,
@PathVariable String relabelNo,
@Valid @RequestBody RelabelRequests.ChangeStatus request) {
return ApiResponse.success(service.changeStatus(scope(user, customerId), relabelNo, operator(user), request));
requirePlatform(user);
return ApiResponse.success(service.changeStatus(null, relabelNo, operator(user), request));
}
private String scope(AuthenticatedUser user, String requested) {
private void requirePlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requested == null || requested.isBlank() || "0".equals(requested)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requested.trim();
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required");
}
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
}
@@ -11,9 +11,15 @@ public interface RelabelApplicationMapper extends BaseMapper<RelabelApplication>
@Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1")
RelabelApplication findNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
@Select("SELECT * FROM ll_relabel_application WHERE relabel_no=#{relabelNo} LIMIT 1")
RelabelApplication findAnyByRelabelNo(@Param("relabelNo") String relabelNo);
@Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
RelabelApplication findKey(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE")
RelabelApplication lockNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
@Select("SELECT * FROM ll_relabel_application WHERE relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE")
RelabelApplication lockAnyByRelabelNo(@Param("relabelNo") String relabelNo);
}
@@ -128,7 +128,7 @@ public class RelabelApplicationService {
throw badRequest("createdFrom must not be after createdTo");
}
LambdaQueryWrapper<RelabelApplication> wrapper = new LambdaQueryWrapper<RelabelApplication>()
.eq(RelabelApplication::getCustomerId, customerId)
.eq(StringUtils.hasText(customerId), RelabelApplication::getCustomerId, customerId)
.like(StringUtils.hasText(query.relabelNo()), RelabelApplication::getRelabelNo, query.relabelNo())
.like(StringUtils.hasText(query.customerOrderNo()), RelabelApplication::getCustomerOrderNo, query.customerOrderNo())
.eq(StringUtils.hasText(query.status()), RelabelApplication::getStatus, query.status())
@@ -147,9 +147,10 @@ public class RelabelApplicationService {
@Transactional(readOnly = true)
public RelabelResponses.Detail detail(String customerId, String relabelNo) {
RelabelApplication application = require(customerId, relabelNo);
String recordCustomerId = application.getCustomerId();
List<RelabelItem> detailItems = itemList(application.getId());
List<RelabelStatusHistory> statusHistory = histories.selectList(new LambdaQueryWrapper<RelabelStatusHistory>()
.eq(RelabelStatusHistory::getCustomerId, customerId)
.eq(RelabelStatusHistory::getCustomerId, recordCustomerId)
.eq(RelabelStatusHistory::getApplicationId, application.getId())
.orderByAsc(RelabelStatusHistory::getCreatedAt));
return new RelabelResponses.Detail(summary(application, detailItems), application.getRemark(),
@@ -161,8 +162,11 @@ public class RelabelApplicationService {
@Transactional
public RelabelResponses.Detail changeStatus(String customerId, String relabelNo, String operatorId,
RelabelRequests.ChangeStatus request) {
RelabelApplication application = applications.lockNo(customerId, relabelNo);
RelabelApplication application = StringUtils.hasText(customerId)
? applications.lockNo(customerId, relabelNo)
: applications.lockAnyByRelabelNo(relabelNo);
if (application == null) throw notFound("Relabel application not found");
customerId = application.getCustomerId();
String next = request.status().trim().toUpperCase();
if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status");
if (next.equals(application.getStatus())) return detail(customerId, relabelNo);
@@ -232,7 +236,9 @@ public class RelabelApplicationService {
.orderByAsc(RelabelItem::getId));
}
private RelabelApplication require(String customerId, String relabelNo) {
RelabelApplication value = applications.findNo(customerId, relabelNo);
RelabelApplication value = StringUtils.hasText(customerId)
? applications.findNo(customerId, relabelNo)
: applications.findAnyByRelabelNo(relabelNo);
if (value == null) throw notFound("Relabel application not found");
return value;
}
@@ -62,7 +62,8 @@ public class LastlegOrderService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one order is required");
}
List<LastlegOrder> orders = orderMapper.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, ids).last("FOR UPDATE"));
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
.in(LastlegOrder::getId, ids).last("FOR UPDATE"));
if (orders.size() != ids.size()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more tail-leg orders were not found");
}
@@ -123,7 +124,7 @@ public class LastlegOrderService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo");
}
return new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId)
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.orderNo()), LastlegOrder::getOrderNo, query.orderNo())
.like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode())
.like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo())
@@ -139,7 +140,8 @@ public class LastlegOrderService {
private LastlegOrder findOwned(String customerId, long orderId) {
LastlegOrder order = orderMapper.selectOne(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getId, orderId));
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getId, orderId));
if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found");
return order;
}
@@ -153,7 +155,7 @@ public class LastlegOrderService {
private LastlegOrderSummary summary(LastlegOrder o) {
return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(), o.getSourceInboundId(),
o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerName(), o.getOverseasWarehouseId(),
o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(),
o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(),
o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
@@ -340,14 +340,14 @@ public class WarehouseTransferService {
for (WarehouseTransferInventoryAllocation allocation : allocations) {
if (!"RESERVED".equals(allocation.getStatus())) throw conflict("Warehouse transfer allocation was already processed");
String keyBase = "WAREHOUSE_TRANSFER:" + transferNo + ":" + allocation.getInventoryPositionId();
InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(customerId,
InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(
allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo,
"WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":ALLOCATE", trim(request.remark()));
inventory.allocate(customerId, operatorId, move);
inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(customerId,
inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(
allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo,
"WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":PICK", trim(request.remark())));
inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(customerId,
inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(
allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo,
"WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":SHIP", trim(request.remark())));
allocation.setStatus("SHIPPED"); allocation.setActivePositionKey(null);
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS ll_order (
id BIGINT NOT NULL COMMENT '雪花 ID',
order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识',
order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型',
order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型:FIRST_LEG头程订单、RELABEL_APPLICATION换标下单、WAREHOUSE_TRANSFER仓储转运、LOCAL_LOOSEBOARD本土散板、RETURN_RELABEL退货换标、ONE_CLICK_DROPSHIP一件代发',
inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统',
source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键',
@@ -57,7 +57,7 @@ CREATE TABLE IF NOT EXISTS ll_order (
relabel_order_no VARCHAR(128) COMMENT '换标单号',
relabel_status VARCHAR(32) COMMENT '换标状态',
pallet_merge_flag VARCHAR(32) COMMENT '合托标记',
current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态',
current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态:PENDING_ASSIGNMENT待分配、PENDING_INBOUND待入库、INBOUND_COMPLETED入库完成、PENDING_DELIVERY待派送、DELIVERING派送中、DELIVERED派送完成',
sub_order_status VARCHAR(255) NULL COMMENT '子单据状态汇总',
private_remark VARCHAR(1000) COMMENT '备注(仅自己)',
customer_remark VARCHAR(1000) COMMENT '备注(客户)',
@@ -171,8 +171,84 @@ paths:
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [version, status], properties: { version: { type: integer, format: int64, minimum: 0 }, status: { $ref: '#/components/schemas/LooseboardStatus' }, remark: { type: string, nullable: true, maxLength: 1000 } } } } } }
responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 }, '409': { description: 非法状态迁移 } }
/api/platform/v1/last-leg/relabel-applications:
get:
summary: 分页查询海外换标单
description: 权限 platform:overseas-warehouse:relabel-order:list;平台查询全部换标单,不接收 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: relabelNo, in: query, schema: { type: string, maxLength: 64 } }
- { name: customerOrderNo, in: query, schema: { type: string, maxLength: 128 } }
- { name: skuCode, in: query, schema: { type: string, maxLength: 128 } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/RelabelStatus' } }
- { name: overseasWarehouseId, in: query, schema: { type: string, maxLength: 128 } }
- { name: createdFrom, in: query, schema: { type: string, format: date-time } }
- { name: createdTo, in: query, schema: { type: string, format: date-time } }
responses: { '200': { description: 统一 ApiResponse 包装的换标单分页 } }
post:
summary: 创建海外换标单
description: 权限 platform:overseas-warehouse:relabel-order:create;明细与换标文件按请求快照保存,使用 idempotencyKey 幂等。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCreate' } } } }
responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 幂等键冲突 } }
/api/platform/v1/last-leg/relabel-applications/{relabelNo}:
get:
summary: 查询海外换标单详情
description: 权限 platform:overseas-warehouse:relabel-order:list。
security: [{ bearerAuth: [] }]
parameters:
- { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }
responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '404': { description: 换标单不存在或不属于当前客户 } }
/api/platform/v1/last-leg/relabel-applications/{relabelNo}/status:
put:
summary: 变更海外换标单状态
description: 权限 platform:overseas-warehouse:relabel-order:status;状态按 PENDING_PROCESSING→PROCESSING→COMPLETED/REJECTED/CANCELLED 流转。
security: [{ bearerAuth: [] }]
parameters:
- { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelStatusChange' } } } }
responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } }
components:
schemas:
RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, COMPLETED, REJECTED, CANCELLED] }
RelabelCreate:
type: object
required: [customerOrderNo, overseasWarehouseId, labelDeadline, contactName, contactPhone, idempotencyKey, items]
properties:
customerOrderNo: { type: string, maxLength: 128 }
overseasWarehouseId: { type: string, maxLength: 128 }
labelDeadline: { type: string, format: date-time }
contactName: { type: string, maxLength: 128 }
contactPhone: { type: string, maxLength: 64 }
remark: { type: string, nullable: true, maxLength: 1000 }
idempotencyKey: { type: string, maxLength: 255 }
items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/RelabelItem' } }
RelabelItem:
type: object
required: [skuCode, productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, newSkuCode]
properties:
skuCode: { type: string, maxLength: 128 }
productImageFileId: { type: string, nullable: true, maxLength: 128 }
productImageFileName: { type: string, nullable: true, maxLength: 255 }
productImageUrl: { type: string, nullable: true, maxLength: 1000 }
productNameZh: { type: string, maxLength: 255 }
totalPackageCount: { type: integer, minimum: 1 }
notifiedPackageCount: { type: integer, minimum: 1 }
shipmentNo: { type: string, maxLength: 128 }
trackingCode: { type: string, nullable: true, maxLength: 255 }
newSkuCode: { type: string, maxLength: 128 }
labelRequirement: { type: string, nullable: true, maxLength: 1000 }
relabelFileId: { type: string, nullable: true, maxLength: 128 }
relabelFileName: { type: string, nullable: true, maxLength: 255 }
relabelFileUrl: { type: string, nullable: true, maxLength: 1000 }
RelabelStatusChange:
type: object
required: [status]
properties:
status: { $ref: '#/components/schemas/RelabelStatus' }
remark: { type: string, nullable: true, maxLength: 1000 }
ProcurementStatus: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] }
ProcurementCreate:
type: object
@@ -9,14 +9,15 @@ import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
@@ -27,17 +28,15 @@ class LastlegOrderControllerTest {
1, 20, null, null, null, null, null, null, null, null, null, null);
@Test
void customerCannotOverrideItsTenantScope() {
ApiResponse<PageResult<LastlegOrderSummary>> response =
controller.list(user(100L, "MERCHANT"), "200", query);
assertNotNull(response);
verify(service).page(eq("100"), eq(query));
void platformEndpointRejectsMerchantIdentity() {
assertThrows(ResponseStatusException.class,
() -> controller.list(user(100L, "MERCHANT"), "200", query));
}
@Test
void platformOperatorMustExplicitlyChooseCustomerTenant() {
assertThrows(ResponseStatusException.class,
() -> controller.list(user(0L, "PLATFORM"), null, query));
void platformOperatorCanQueryAllCustomerTenantsWithoutCustomerId() {
controller.list(user(0L, "PLATFORM"), null, query);
verify(service).page(isNull(), eq(query));
}
@Test
@@ -0,0 +1,50 @@
package com.baiyue.lastleg.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.service.LastlegOrderService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class MerchantLastlegOrderControllerTest {
private final LastlegOrderService service = mock(LastlegOrderService.class);
private final MerchantLastlegOrderController controller = controller();
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query(
1, 20, null, null, null, null, null, null, null, null, null, null);
@Test
void merchantListUsesOnlyAuthenticatedTenant() {
controller.list(user(100L, "MERCHANT"), query);
verify(service).page(eq("100"), eq(query));
}
@Test
void merchantDetailUsesOnlyAuthenticatedTenant() {
controller.detail(user(100L, "MERCHANT"), 11L);
verify(service).detail("100", 11L);
}
@Test
void merchantEndpointRejectsPlatformIdentity() {
assertThrows(ResponseStatusException.class,
() -> controller.list(user(0L, "PLATFORM"), query));
}
private static AuthenticatedUser user(long tenantId, String appCode) {
return new AuthenticatedUser(1L, tenantId, tenantId, 1L, "tester", appCode, "WEB", Set.of());
}
private MerchantLastlegOrderController controller() {
MerchantLastlegOrderController controller = new MerchantLastlegOrderController();
ReflectionTestUtils.setField(controller, "orderService", service);
return controller;
}
}
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.controller;
import com.baiyue.lastleg.delivery.controller.DeliveryTransferController;
import com.baiyue.lastleg.dispatch.controller.DispatchController;
import com.baiyue.lastleg.dispatch.controller.DispatchQueryController;
import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController;
import com.baiyue.lastleg.inboundplan.pda.controller.InboundPalletAssignmentController;
import com.baiyue.lastleg.inboundplan.pda.controller.InboundPdaController;
@@ -9,6 +10,7 @@ import com.baiyue.lastleg.inventory.command.controller.InventoryCommandControlle
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.GetMapping;
import java.util.Arrays;
import java.util.Set;
@@ -37,6 +39,20 @@ class Phase4BrowserApiBoundaryTest {
}
}
@Test
void dispatchQueryIsIndependentlyEnabledWithoutExposingOutboundCommands() {
ConditionalOnProperty condition = DispatchQueryController.class.getAnnotation(ConditionalOnProperty.class);
assertTrue(condition != null);
assertTrue(Arrays.asList(condition.name()).contains("lastleg.dispatch-query-api.enabled"));
assertFalse(condition.matchIfMissing());
assertTrue(Arrays.stream(DispatchQueryController.class.getDeclaredMethods())
.filter(method -> method.isAnnotationPresent(GetMapping.class)).count() == 2);
assertFalse(Arrays.stream(DispatchQueryController.class.getDeclaredMethods())
.flatMap(method -> Arrays.stream(method.getAnnotations()))
.anyMatch(annotation -> Set.of("PostMapping", "PutMapping", "DeleteMapping")
.contains(annotation.annotationType().getSimpleName())));
}
@Test
void platformInventoryCommandsDoNotMapOutboundAllocationPickingOrShipping() {
Set<String> paths = Arrays.stream(InventoryCommandController.class.getDeclaredMethods())
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.dispatch.controller;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.service.DispatchService;
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.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DispatchQueryControllerTest {
@Mock DispatchService service;
private DispatchQueryController controller;
@BeforeEach
void setUp() {
controller = new DispatchQueryController();
ReflectionTestUtils.setField(controller, "service", service);
}
@Test
void platformPageQueriesAllCustomersWithoutCustomerId() {
DispatchRequests.Query query = new DispatchRequests.Query(1, 20, null, null, null, null);
when(service.page(null, query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
var response = controller.page(platform(), query);
verify(service).page(null, query);
assertThat(response.code()).isEqualTo("200");
assertThat(response.data().records()).isEmpty();
assertThatThrownBy(() -> controller.page(null, query))
.isInstanceOf(ResponseStatusException.class)
.satisfies(error -> org.assertj.core.api.Assertions.assertThat(
((ResponseStatusException) error).getStatusCode().value()).isEqualTo(401));
}
private static AuthenticatedUser platform() {
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of());
}
}
@@ -104,6 +104,19 @@ class DispatchServiceTest {
verify(orders, never()).updateById(any(DispatchOrder.class));
}
@Test
void platformDetailFindsDispatchGloballyAndLoadsItemsWithRecordCustomer() {
DispatchOrder dispatch = new DispatchOrder();
dispatch.setId(1L);
dispatch.setCustomerId("200");
dispatch.setDispatchNo("PC1");
when(orders.findAnyByDispatchNo("PC1")).thenReturn(dispatch);
service.detail(null, "PC1");
verify(items).findByDispatchOrder("200", 1L);
}
private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) {
return new DispatchPalletCandidate(1L, planPalletId, warehousePalletId, 2L, "WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1",
"LIP1-P001", destination, destination, destination, destination, 12,
@@ -36,18 +36,14 @@ class InboundPlanControllerTest {
}
@Test
void platformPageRequiresAndPassesExplicitCustomerId() {
void platformPageAllowsAllCustomersWhenCustomerIdIsOmitted() {
InboundPlanRequests.Query query = new InboundPlanRequests.Query(1, 20, null, null, null,
null, null, null, null, null, null, null, null);
when(service.page("100", query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
controller.page(platform(), "100", query);
controller.page(platform(), null, query);
verify(service).page("100", query);
assertThatThrownBy(() -> controller.page(platform(), "", query))
.isInstanceOf(ResponseStatusException.class)
.satisfies(error -> org.assertj.core.api.Assertions.assertThat(
((ResponseStatusException) error).getStatusCode().value()).isEqualTo(400));
verify(service).page(null, query);
}
@Test
@@ -97,7 +97,7 @@ class InventoryCommandServiceTest {
assertThrows(ResponseStatusException.class,
() -> service.hold("100", "7", new InventoryCommandRequests.Move(
"100", 201L, 6, "ORD-1", null, null, "hold-1", null)));
201L, 6, "ORD-1", null, null, "hold-1", null)));
verify(positions, never()).updateById(any(InventoryPosition.class));
verify(balances, never()).updateById(any(InventoryBalance.class));
}
@@ -129,7 +129,7 @@ class InventoryCommandServiceTest {
when(commands.palletPackages("100", 300L)).thenReturn(0L);
var result = service.ship("100", "7", new InventoryCommandRequests.Move(
"100", 201L, 5, "OUT-1", "OUT-1", null, "ship-1", null));
201L, 5, "OUT-1", "OUT-1", null, "ship-1", null));
assertTrue(result.palletEmpty());
assertEquals("EMPTY", position.getStatus());
@@ -137,6 +137,23 @@ class InventoryCommandServiceTest {
verify(ledgers).insert(any(InventoryLedger.class));
}
@Test
void platformHoldDerivesCustomerFromInventoryPosition() {
InventoryBalance balance = balance(200L, 5);
InventoryPosition position = position(201L, 200L, 5);
position.setCustomerId("200");
when(commands.findPosition(201L)).thenReturn(position);
when(commands.lockPosition("200", 201L)).thenReturn(position);
when(commands.lockBalance("200", 200L)).thenReturn(balance);
InventoryCommandRequests.Move request = new InventoryCommandRequests.Move(
201L, 2, "ORD-1", null, null, "platform-hold-1", null);
service.holdPlatform("7", request);
verify(commands).lockPosition("200", 201L);
verify(operations).findIdempotency("200", "platform-hold-1");
}
private InventoryCommandRequests.StockIn stockIn(String inbound, String key, long packages, long quantity) {
return new InventoryCommandRequests.StockIn("100", "Customer 100", "WH-1", "WH1", "Warehouse 1",
"DEST-1", "ONT8", "ONT8", 101L, inbound, "IN-CODE-1", "SKU-1", "Product 1", null,
@@ -44,14 +44,14 @@ class InventoryCommandControllerTest {
@Test
void permittedBackOfficeUserCanRunInventoryFormCommand() {
var request = move();
when(service.hold("100", "7", request)).thenReturn(response());
when(service.holdPlatform("7", request)).thenReturn(response());
controller.hold(user(Set.of(InventoryCommandController.MANAGE_PERMISSION)), request);
verify(service).hold("100", "7", request);
verify(service).holdPlatform("7", request);
}
@Test
void physicalAdjustmentRequiresDedicatedPermission() {
var request = new InventoryCommandRequests.Adjustment("100", 1L, 2, "COUNT", "ORD-1", "adj-1", null);
var request = new InventoryCommandRequests.Adjustment(1L, 2, "COUNT", "ORD-1", "adj-1", null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> controller.adjust(user(Set.of(InventoryCommandController.MANAGE_PERMISSION)), request));
assertEquals(403, exception.getStatusCode().value());
@@ -59,11 +59,11 @@ class InventoryCommandControllerTest {
}
private InventoryCommandRequests.Move move() {
return new InventoryCommandRequests.Move("100", 1L, 2, "ORD-1", null, null, "hold-1", null);
return new InventoryCommandRequests.Move(1L, 2, "ORD-1", null, null, "hold-1", null);
}
private AuthenticatedUser user(Set<String> permissions) {
return new AuthenticatedUser(7L, 100L, 100L, 8L, "operator", "MERCHANT", "WEB", permissions);
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", permissions);
}
private InventoryCommandResponse response() {
@@ -21,21 +21,21 @@ class InventoryQueryControllerTest {
1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null);
@Test
void merchantCannotOverrideCustomerScope() {
controller.balances(user(100L, "MERCHANT"), "200", query);
verify(service).balances(eq("100"), eq(query));
void platformBalancePageQueriesAllCustomers() {
controller.balances(user(0L, "PLATFORM"), query);
verify(service).balances(eq(null), eq(query));
}
@Test
void platformMustSelectCustomer() {
void merchantCannotUsePlatformInventoryController() {
assertThrows(ResponseStatusException.class,
() -> controller.summary(user(0L, "PLATFORM"), null, query));
() -> controller.summary(user(100L, "MERCHANT"), query));
}
@Test
void platformCanQuerySelectedCustomer() {
controller.summary(user(0L, "PLATFORM"), "200", query);
verify(service).dashboard(eq("200"), eq(query));
void platformSummaryQueriesAllCustomers() {
controller.summary(user(0L, "PLATFORM"), query);
verify(service).dashboard(eq(null), eq(query));
}
@Test
@@ -43,7 +43,7 @@ class InventoryQueryControllerTest {
InventoryQueryRequests.PositionQuery positionQuery =
new InventoryQueryRequests.PositionQuery(null, null, null);
assertThrows(ResponseStatusException.class,
() -> controller.positions(null, null, 1L, positionQuery));
() -> controller.positions(null, 1L, positionQuery));
}
private static AuthenticatedUser user(long customerId, String appCode) {
@@ -65,6 +65,16 @@ class InventoryQueryServiceTest {
verify(mapper).balances("100", query, 0, 20);
}
@Test
void platformBalancePagePassesNoCustomerFilter() {
InventoryQueryRequests.BalanceQuery query = balanceQuery(null);
when(mapper.countBalances(null, query)).thenReturn(0L);
service.balances(null, query);
verify(mapper).countBalances(null, query);
}
@Test
void unknownStatusIsRejectedBeforeDatabaseQuery() {
InventoryQueryRequests.BalanceQuery query = balanceQuery("DAMAGED");
@@ -102,6 +112,20 @@ class InventoryQueryServiceTest {
assertEquals(7L, result.totalPackages());
}
@Test
void platformPositionDetailDerivesCustomerFromBalance() {
InventoryQueryRequests.PositionQuery query = new InventoryQueryRequests.PositionQuery(null, null, null);
InventoryBalance balance = new InventoryBalance();
balance.setId(99L);
balance.setCustomerId("200");
when(mapper.findBalance(99L)).thenReturn(balance);
when(mapper.positions("200", 99L, query)).thenReturn(List.of());
service.positions(null, 99L, query);
verify(mapper).positions("200", 99L, query);
}
@Test
void reversedLedgerDateRangeIsRejected() {
InventoryQueryRequests.LedgerQuery query = new InventoryQueryRequests.LedgerQuery(
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.model;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertEquals;
class LastlegOrderTypeTest {
@Test
void exposesTheSupportedBusinessTypes() {
assertEquals(
Arrays.asList("FIRST_LEG", "RELABEL_APPLICATION", "WAREHOUSE_TRANSFER",
"LOCAL_LOOSEBOARD", "RETURN_RELABEL", "ONE_CLICK_DROPSHIP"),
Arrays.stream(LastlegOrderType.values()).map(Enum::name).toList());
assertEquals("换标下单", LastlegOrderType.RELABEL_APPLICATION.label());
assertEquals("本土散板", LastlegOrderType.LOCAL_LOOSEBOARD.label());
}
}
@@ -37,4 +37,20 @@ class RelabelContractTest {
assertTrue(schema.contains("request_hash"));
}
}
@Test
void openApiDocumentsAllRelabelApplicationOperations() throws Exception {
String openApi = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"), StandardCharsets.UTF_8);
assertTrue(openApi.contains("/api/platform/v1/last-leg/relabel-applications:"));
assertTrue(openApi.contains("/api/platform/v1/last-leg/relabel-applications/{relabelNo}:"));
assertTrue(openApi.contains("/api/platform/v1/last-leg/relabel-applications/{relabelNo}/status:"));
assertTrue(openApi.contains("RelabelCreate"));
assertTrue(openApi.contains("RelabelStatusChange"));
assertTrue(openApi.contains("platform:overseas-warehouse:relabel-order:list"));
assertTrue(openApi.contains("platform:overseas-warehouse:relabel-order:create"));
assertTrue(openApi.contains("platform:overseas-warehouse:relabel-order:status"));
String relabelPaths = openApi.substring(openApi.indexOf("/api/platform/v1/last-leg/relabel-applications:"),
openApi.indexOf("components:"));
assertFalse(relabelPaths.contains("name: customerId"));
}
}
@@ -52,6 +52,7 @@ class LastlegOrderServiceTest {
var response = service.detail("100", 11L);
assertEquals(11L, response.order().id());
assertEquals("100", response.order().customerId());
assertTrue(java.util.Arrays.stream(response.getClass().getRecordComponents())
.noneMatch(component -> "dropshipPallets".equals(component.getName())));
assertTrue(java.util.Arrays.stream(com.baiyue.lastleg.dto.LastlegOrderDetailResponse.History.class.getRecordComponents())
+8
View File
@@ -3682,6 +3682,14 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照'' AFTER product_name_zh'));
PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028;
-- 旧版本库存余额使用 sku_name_zh;升级时保留名称快照并兼容新查询字段。
SET @ddl = (SELECT IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='sku_name_zh')
AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_zh'),
'UPDATE ll_inventory_balance SET product_name_zh = COALESCE(NULLIF(product_name_zh, ''''), sku_name_zh) WHERE sku_name_zh IS NOT NULL',
'SELECT 1'));
PREPARE phase4_inventory_legacy_name_028 FROM @ddl; EXECUTE phase4_inventory_legacy_name_028; DEALLOCATE PREPARE phase4_inventory_legacy_name_028;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='sku_code'),
'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码快照''',
'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码快照'' AFTER inbound_code'));
@@ -90,8 +90,8 @@ public class PlatformWarehouseController {
}
@Operation(
summary = "List tenant warehouses / 查询租户仓库",
description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST + ". Returns a customer-scoped warehouse page with optional filters."
summary = "List warehouses / 查询仓库",
description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST + ". Returns all warehouses with optional filters."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@@ -102,12 +102,9 @@ public class PlatformWarehouseController {
@PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "')")
public ApiResponse<PageResult<WarehouseEntity>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true)
@RequestParam @NotNull Long customerId,
@Valid WarehouseRequests.QueryRequest request) {
requirePlatform(user);
requireTenant(customerId);
return ApiResponse.success(service.page(customerId, request));
return ApiResponse.success(service.pageAll(request));
}
@Operation(
@@ -85,6 +85,29 @@ public class WarehouseRepository {
return PageResult.from(warehouses.selectPage(new Page<>(query.page(), query.size()), wrapper));
}
public PageResult<WarehouseEntity> pageAll(WarehouseRequests.QueryRequest query) {
LambdaQueryWrapper<WarehouseEntity> wrapper = new LambdaQueryWrapper<WarehouseEntity>()
.eq(StringUtils.hasText(query.status()), WarehouseEntity::getStatus, query.status())
.eq(StringUtils.hasText(query.countryCode()), WarehouseEntity::getCountryCode, query.countryCode())
.orderByDesc(WarehouseEntity::getCreatedAt);
if (StringUtils.hasText(query.capabilityCode())) {
List<Long> ids = capabilities.selectList(new LambdaQueryWrapper<ServiceCapabilityEntity>()
.select(ServiceCapabilityEntity::getWarehouseId)
.eq(ServiceCapabilityEntity::getCapabilityCode, query.capabilityCode())
.eq(ServiceCapabilityEntity::getEnabled, true))
.stream()
.map(ServiceCapabilityEntity::getWarehouseId)
.distinct()
.toList();
if (ids.isEmpty()) {
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
}
wrapper.in(WarehouseEntity::getId, ids);
}
return PageResult.from(warehouses.selectPage(new Page<>(query.page(), query.size()), wrapper));
}
public List<WarehouseAddressEntity> findAddresses(long customerId, long warehouseId) {
return addresses.selectList(new LambdaQueryWrapper<WarehouseAddressEntity>()
.eq(WarehouseAddressEntity::getCustomerId, customerId)
@@ -87,6 +87,10 @@ public class WarehouseApplicationService {
return repository.page(customerId, query);
}
public PageResult<WarehouseEntity> pageAll(WarehouseRequests.QueryRequest query) {
return repository.pageAll(query);
}
public WarehouseDetailResponse detail(long customerId, long id) {
WarehouseEntity warehouse = owned(customerId, id);
List<WarehouseAddressEntity> addresses = repository.findAddresses(customerId, id);
@@ -18,17 +18,17 @@ spring:
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858}
enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public}
config:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858}
namespace: ${NACOS_NAMESPACE:public}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
@@ -0,0 +1,34 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.warehouse.dto.WarehouseRequests;
import com.baiyue.warehouse.service.WarehouseApplicationService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.Set;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class PlatformWarehouseControllerTest {
@Test
void platformPageQueriesAllWarehousesWithoutCustomerId() {
WarehouseApplicationService service = mock(WarehouseApplicationService.class);
PlatformWarehouseController controller = new PlatformWarehouseController();
ReflectionTestUtils.setField(controller, "service", service);
WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(1, 20, "ACTIVE", null, null);
when(service.pageAll(query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
controller.page(platform(), query);
verify(service).pageAll(query);
}
private static AuthenticatedUser platform() {
return new AuthenticatedUser(1L, 0L, 0L, 1L, "operator", "PLATFORM", "WEB", Set.of());
}
}
@@ -18,6 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class WarehouseApplicationServiceTest {
@@ -48,6 +49,16 @@ class WarehouseApplicationServiceTest {
assertThat(location.getValue().getStatus()).isEqualTo("ACTIVE");
}
@Test
void platformWarehousePageUsesGlobalRepositoryQuery() {
WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(1, 20, "ACTIVE", "US", null);
when(repository.pageAll(query)).thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(), 1, 20, 0, 0));
service.pageAll(query);
verify(repository).pageAll(query);
}
private static WarehouseRequests.SaveRequest request() {
WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest(
"WAREHOUSE", "US", "CA", "Los Angeles", null, "90001",