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} cell: ${DEPLOYMENT_CELL:prod-overseas}
data-residency: ${DATA_RESIDENCY:OVERSEAS} data-residency: ${DATA_RESIDENCY:OVERSEAS}
security: security:
issuer: ${JWT_ISSUER:https://auth.example.com} issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api} 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} internal-service-token: ${INTERNAL_SERVICE_TOKEN}
permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot}
openapi.enabled: ${SWAGGER_ENABLED:false} openapi.enabled: ${SWAGGER_ENABLED:false}
@@ -4,6 +4,9 @@ spring:
username: ${LASTLEG_DB_USERNAME} username: ${LASTLEG_DB_USERNAME}
password: ${LASTLEG_DB_PASSWORD} password: ${LASTLEG_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
lastleg:
dispatch-query-api:
enabled: true
baiyue: baiyue:
clients: clients:
domestic-warehouse: 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')); '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; 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'), 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 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')); '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 入仓、换标、产品与箱唛快照、库存、海外采购和本土卡车;本土散板新增实现也归本服务的独立稳定子域。 - 四期在本服务内只进入尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购和本土卡车;本土散板新增实现也归本服务的独立稳定子域。
- `dispatch/`、`delivery/`、出库单、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/`,本服务不得复制仓储资源主数据或另建公网认证入口。 - 海外仓库、库位、实物托盘和资源锁归 `warehouse-overseas-service/`;浏览器 API 统一经 `gateway-overseas/`,本服务不得复制仓储资源主数据或另建公网认证入口。
## 数据库表索引与注释约束 ## 数据库表索引与注释约束
@@ -73,6 +73,7 @@
## 查询与租户约束 ## 查询与租户约束
- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。 - 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。
- 尾程平台只读查询接口的 `customerId` 为可选筛选条件:平台身份省略时查询全部客户数据,传入时按指定客户过滤;商户身份忽略客户端传入值并始终按 Token 中的自身客户隔离。创建、编辑、审核等需要明确目标客户的写接口仍必须传入 `customerId`。
- 常规入仓库存余额以客户、海外仓、目的仓、入仓单和 SKU 为唯一业务维度;PDA 完成入库时按冻结商品明细精确拆分 SKU 箱数,转仓收货继承来源库存 SKU,不得回退为仅按入仓单汇总。 - 常规入仓库存余额以客户、海外仓、目的仓、入仓单和 SKU 为唯一业务维度;PDA 完成入库时按冻结商品明细精确拆分 SKU 箱数,转仓收货继承来源库存 SKU,不得回退为仅按入仓单汇总。
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 - 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 - 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
@@ -56,7 +56,7 @@ public class LastlegOrderController {
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@Valid LastlegOrderRequests.Query query) { @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}") @GetMapping("/api/platform/v1/last-leg/orders/{id}")
@@ -66,7 +66,7 @@ public class LastlegOrderController {
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@PathVariable long id) { @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") @GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv")
@@ -76,7 +76,7 @@ public class LastlegOrderController {
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@Valid LastlegOrderRequests.Query query) { @Valid LastlegOrderRequests.Query query) {
byte[] body = orderService.exportCsv(scope(user, customerId), query); byte[] body = orderService.exportCsv(platformScope(user, customerId), query);
return ResponseEntity.ok() return ResponseEntity.ok()
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv") .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv")
@@ -91,7 +91,7 @@ public class LastlegOrderController {
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@PathVariable long id, @PathVariable long id,
@Valid @RequestBody LastlegOrderRequests.SingleTransferDestination request) { @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") @PutMapping("/api/platform/v1/last-leg/orders/destination")
@@ -101,20 +101,17 @@ public class LastlegOrderController {
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@Valid @RequestBody LastlegOrderRequests.TransferDestination request) { @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) { if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
} }
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required");
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform queries must specify a customer");
}
return requestedCustomer;
} }
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; package com.baiyue.lastleg.dispatch.controller;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests; import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse; import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
@@ -61,24 +60,6 @@ public class DispatchController {
return ApiResponse.success(service.preArrange(scopedTenant, operatorId, request)); 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}") @PutMapping("/{dispatchNo}")
@Operation(summary = "Edit vehicle, driver, appointment, route and departure data") @Operation(summary = "Edit vehicle, driver, appointment, route and departure data")
public ApiResponse<DispatchResponse> update( 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") @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); 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") @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); 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); long picked = picks.totalItem(customerId, task.getId(), itemId);
if (picked + request.packages() > item.getPackageCount()) throw conflict("Picked packages exceed dispatch pallet packages"); if (picked + request.packages() > item.getPackageCount()) throw conflict("Picked packages exceed dispatch pallet packages");
String keyBase = "DISPATCH:" + order.getDispatchNo() + ":" + itemId + ":" + request.idempotencyKey(); 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, request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":ALLOCATE", request.remark()); keyBase + ":ALLOCATE", request.remark());
inventory.allocate(customerId, operatorId, move); 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, request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":PICK", request.remark())); keyBase + ":PICK", request.remark()));
DispatchPdaPick pick = new DispatchPdaPick(); DispatchPdaPick pick = new DispatchPdaPick();
@@ -200,7 +200,7 @@ public class DispatchPdaWorkflowService {
long shipped = 0; long shipped = 0;
for (DispatchPdaPick pick : dispatchPicks) { for (DispatchPdaPick pick : dispatchPicks) {
String key = "DISPATCH:" + order.getDispatchNo() + ":PICK:" + pick.getId() + ":SHIP"; 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()), pick.getInventoryPositionId(), n(pick.getPackages()),
outbound.getOutboundNo(), outbound.getOutboundNo(), null, key, request.remark())); outbound.getOutboundNo(), outbound.getOutboundNo(), null, key, request.remark()));
shipped += n(pick.getPackages()); shipped += n(pick.getPackages());
@@ -150,7 +150,7 @@ public class DispatchService {
public PageResult<DispatchResponse> page(String customerId, DispatchRequests.Query query) { public PageResult<DispatchResponse> page(String customerId, DispatchRequests.Query query) {
Page<DispatchOrder> page = orders.selectPage(new Page<>(query.page(), query.size()), 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.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo())
.like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate()) .like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate())
.eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status()) .eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status())
@@ -158,13 +158,13 @@ public class DispatchService {
query.fbaAppointmentNo()) query.fbaAppointmentNo())
.orderByDesc(DispatchOrder::getCreatedAt)); .orderByDesc(DispatchOrder::getCreatedAt));
List<DispatchResponse> records = page.getRecords().stream() 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()); return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
} }
public DispatchResponse detail(String customerId, String dispatchNo) { public DispatchResponse detail(String customerId, String dispatchNo) {
DispatchOrder dispatch = find(customerId, dispatchNo); DispatchOrder dispatch = find(customerId, dispatchNo);
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
} }
@Transactional @Transactional
@@ -216,7 +216,9 @@ public class DispatchService {
} }
private DispatchOrder find(String customerId, String dispatchNo) { 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"); if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
return dispatch; return dispatch;
} }
@@ -13,6 +13,7 @@ public record LastlegOrderSummary(
String sourceInboundId, String sourceInboundId,
String inboundCode, String inboundCode,
String sourceOrderNo, String sourceOrderNo,
String customerId,
String customerName, String customerName,
String overseasWarehouseId, String overseasWarehouseId,
String overseasWarehouseName, String overseasWarehouseName,
@@ -37,7 +37,7 @@ public class InboundPlanController {
@Operation(summary = "分页查询入库订单", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) @Operation(summary = "分页查询入库订单", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
public ApiResponse<PageResult<InboundPlanListResponse>> page( public ApiResponse<PageResult<InboundPlanListResponse>> page(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @RequestParam(required = false) String customerId,
@Valid InboundPlanRequests.Query query) { @Valid InboundPlanRequests.Query query) {
return ApiResponse.success(service.page(scope(user, customerId), 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 ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, return null;
"Platform request must specify target tenant");
} }
return requestedCustomer; return requestedCustomer.trim();
} }
return String.valueOf(user.tenantId()); return String.valueOf(user.tenantId());
} }
@@ -11,14 +11,15 @@ import java.util.List;
@Mapper @Mapper
public interface InboundPlanQueryMapper { public interface InboundPlanQueryMapper {
String FROM_AND_FILTER = " FROM ll_inbound_plan_line l" + 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_inbound_plan p ON p.id=l.plan_id" +
" JOIN ll_order o ON o.id=l.order_id AND o.customer_id=#{customerId}" + " 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" + " 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" + " 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" + " 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)" + " 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" + " WHERE 1=1" +
"<if test='q.planNo != null and q.planNo != \"\"'> AND p.plan_no LIKE CONCAT('%',#{q.planNo},'%')</if>" + "<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>" + "<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>" + "<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>" + "<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>" + "<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>" + "<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.customer_id=#{customerId} AND di.order_id=l.order_id)</when></choose>" + "<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.createdFrom != null'> AND p.created_at &gt;=#{q.createdFrom}</if>" +
"<if test='q.createdTo != null'> AND p.created_at &lt;=#{q.createdTo}</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) { private InboundFbaAppointment find(String customerId, String appointmentNo) {
return appointmentMapper.selectOne(new LambdaQueryWrapper<InboundFbaAppointment>() LambdaQueryWrapper<InboundFbaAppointment> wrapper = new LambdaQueryWrapper<InboundFbaAppointment>()
.eq(InboundFbaAppointment::getCustomerId, customerId) .eq(InboundFbaAppointment::getAppointmentNo, appointmentNo);
.eq(InboundFbaAppointment::getAppointmentNo, appointmentNo)); if (customerId != null) wrapper.eq(InboundFbaAppointment::getCustomerId, customerId);
return appointmentMapper.selectOne(wrapper);
} }
private List<InboundFbaAppointmentItem> items(Long appointmentId) { private List<InboundFbaAppointmentItem> items(Long appointmentId) {
@@ -117,8 +117,10 @@ public class InboundPlanService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public InboundPlanResponse detailByPlanNo(String customerId, String planNo) { public InboundPlanResponse detailByPlanNo(String customerId, String planNo) {
InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper<InboundPlan>() LambdaQueryWrapper<InboundPlan> wrapper = new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo)); .eq(InboundPlan::getPlanNo, planNo);
if (customerId != null) wrapper.eq(InboundPlan::getCustomerId, customerId);
InboundPlan plan = planMapper.selectOne(wrapper);
if (plan == null) { if (plan == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
} }
@@ -244,7 +246,7 @@ public class InboundPlanService {
} }
private InboundPlanResponse detail(String customerId, InboundPlan plan) { 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"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
} }
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>() List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
@@ -33,7 +33,8 @@ public class InventoryCommandController {
public ApiResponse<InventoryCommandResponse> hold(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InventoryCommandResponse> hold(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) { @Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION); 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") @PostMapping("/release")
@@ -42,7 +43,8 @@ public class InventoryCommandController {
public ApiResponse<InventoryCommandResponse> release(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InventoryCommandResponse> release(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) { @Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION); 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") @PostMapping("/adjust")
@@ -51,19 +53,15 @@ public class InventoryCommandController {
public ApiResponse<InventoryCommandResponse> adjust(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InventoryCommandResponse> adjust(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Adjustment request) { @Valid @RequestBody InventoryCommandRequests.Adjustment request) {
requirePermission(user, ADJUST_PERMISSION); 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 (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required");
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform request must specify target tenant");
}
return requestedCustomer.trim();
} }
return String.valueOf(user.tenantId());
} }
private String operator(AuthenticatedUser user) { private String operator(AuthenticatedUser user) {
@@ -39,7 +39,6 @@ public final class InventoryCommandRequests {
} }
public record Move( public record Move(
String customerId,
@NotNull Long positionId, @NotNull Long positionId,
@Positive long packages, @Positive long packages,
String businessOrderNo, String businessOrderNo,
@@ -51,7 +50,6 @@ public final class InventoryCommandRequests {
} }
public record Adjustment( public record Adjustment(
String customerId,
@NotNull Long positionId, @NotNull Long positionId,
long packageDelta, long packageDelta,
@NotBlank String reason, @NotBlank String reason,
@@ -8,6 +8,9 @@ import org.apache.ibatis.annotations.Select;
@Mapper @Mapper
public interface InventoryCommandMapper { 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") @Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND id=#{positionId} LIMIT 1 FOR UPDATE")
InventoryPosition lockPosition(@Param("customerId") String customerId, InventoryPosition lockPosition(@Param("customerId") String customerId,
@Param("positionId") Long positionId); @Param("positionId") Long positionId);
@@ -67,11 +67,21 @@ public class InventoryCommandService {
return move(customerId, operatorId, "HOLD", Bucket.AVAILABLE, Bucket.HOLD, request); 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 @Transactional
public InventoryCommandResponse release(String customerId, String operatorId, InventoryCommandRequests.Move request) { public InventoryCommandResponse release(String customerId, String operatorId, InventoryCommandRequests.Move request) {
return move(customerId, operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, 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 @Transactional
public InventoryCommandResponse allocate(String customerId, String operatorId, InventoryCommandRequests.Move request) { public InventoryCommandResponse allocate(String customerId, String operatorId, InventoryCommandRequests.Move request) {
return move(customerId, operatorId, "ALLOCATE", Bucket.AVAILABLE, Bucket.ALLOCATED, 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, private InventoryCommandResponse move(String customerId, String operatorId, String operationType,
Bucket from, Bucket to, InventoryCommandRequests.Move request) { Bucket from, Bucket to, InventoryCommandRequests.Move request) {
return execute(customerId, operatorId, operationType, request.idempotencyKey(), request, operation -> { return execute(customerId, operatorId, operationType, request.idempotencyKey(), request, operation -> {
@@ -255,6 +270,12 @@ public class InventoryCommandService {
return new Locked(balance, position); 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, private InventoryCommandResponse execute(String customerId, String operatorId, String operationType,
String key, Object request, Work work) { String key, Object request, Work work) {
String normalizedKey = required(key, "Idempotency key is required"); 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.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; 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.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException; 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) @Operation(summary = "Get inbound-order package inventory totals", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<InventoryDashboardResponse> summary( public ApiResponse<InventoryDashboardResponse> summary(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.BalanceQuery query) { @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") @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) @Operation(summary = "Page inventory by customer, warehouse and inbound order", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<PageResult<InventoryBalanceView>> balances( public ApiResponse<PageResult<InventoryBalanceView>> balances(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.BalanceQuery query) { @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") @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) @Operation(summary = "List pallet and batch positions for one inbound-order balance", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<List<InventoryPositionView>> positions( public ApiResponse<List<InventoryPositionView>> positions(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long balanceId, @PathVariable long balanceId,
@Valid InventoryQueryRequests.PositionQuery query) { @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") @GetMapping(BASE_PATH + "/ledger")
@@ -72,9 +71,9 @@ public class InventoryQueryController {
@Operation(summary = "Page append-only inventory movements", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) @Operation(summary = "Page append-only inventory movements", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<PageResult<InventoryLedgerView>> ledgers( public ApiResponse<PageResult<InventoryLedgerView>> ledgers(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.LedgerQuery query) { @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") @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) @Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
public ResponseEntity<byte[]> exportBalances( public ResponseEntity<byte[]> exportBalances(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.BalanceQuery query) { @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") @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) @Operation(summary = "Export filtered inventory movements as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
public ResponseEntity<byte[]> exportLedgers( public ResponseEntity<byte[]> exportLedgers(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.LedgerQuery query) { @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) { private static ResponseEntity<byte[]> csv(byte[] body, String filename) {
@@ -104,17 +103,12 @@ public class InventoryQueryController {
.body(body); .body(body);
} }
private String scope(AuthenticatedUser user, String requestedCustomer) { private void requirePlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) { if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
} }
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required");
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform queries must specify a customer");
}
return requestedCustomer;
} }
return String.valueOf(user.tenantId());
} }
} }
@@ -11,18 +11,19 @@ import org.apache.ibatis.annotations.Select;
import java.util.List; import java.util.List;
public interface InventoryQueryMapper { 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.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.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.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.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.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.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.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.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.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=#{customerId} AND p.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))</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>" + "<choose>" +
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND available_packages &gt; 0</when>" + "<when test='q.inventoryStatus == \"AVAILABLE\"'> AND available_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_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>" + "<when test='q.inventoryStatus == \"PROCUREMENT\"'> AND procurement_packages &gt; 0</when>" +
"</choose>"; "</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.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.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.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.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.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.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.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.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.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=#{customerId} AND p2.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))</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>" + "<choose>" +
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND b.available_packages &gt; 0</when>" + "<when test='q.inventoryStatus == \"AVAILABLE\"'> AND b.available_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND b.hold_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") @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); 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.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.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>" + "<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, public List<InventoryPositionView> positions(String customerId, long balanceId,
InventoryQueryRequests.PositionQuery query) { InventoryQueryRequests.PositionQuery query) {
validateStatus(query.inventoryStatus(), POSITION_STATUSES); 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) { if (balance == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory balance not found"); 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) { 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 + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_CREATE + "')")
@Operation(summary = "Create a relabel application", description = "Required authority: " + 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, public ApiResponse<RelabelResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid @RequestBody RelabelRequests.Create request) { @Valid @RequestBody RelabelRequests.Create request) {
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request)); requirePlatform(user);
return ApiResponse.success(service.create("0", operator(user), request));
} }
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
@Operation(summary = "Page relabel applications", description = "Required authority: " + 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, public ApiResponse<PageResult<RelabelResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid RelabelRequests.Query query) { @Valid RelabelRequests.Query query) {
return ApiResponse.success(service.page(scope(user, customerId), query)); requirePlatform(user);
return ApiResponse.success(service.page(null, query));
} }
@GetMapping("/{relabelNo}") @GetMapping("/{relabelNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
@Operation(summary = "Get a relabel application", description = "Required authority: " + 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, public ApiResponse<RelabelResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String relabelNo) { @PathVariable String relabelNo) {
return ApiResponse.success(service.detail(scope(user, customerId), relabelNo)); requirePlatform(user);
return ApiResponse.success(service.detail(null, relabelNo));
} }
@PutMapping("/{relabelNo}/status") @PutMapping("/{relabelNo}/status")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')")
@Operation(summary = "Change relabel application status", description = "Required authority: " + 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, public ApiResponse<RelabelResponses.Detail> status(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String relabelNo, @PathVariable String relabelNo,
@Valid @RequestBody RelabelRequests.ChangeStatus request) { @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 (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
if (requested == null || requested.isBlank() || "0".equals(requested)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required");
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requested.trim();
} }
return String.valueOf(user.tenantId());
} }
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } 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") @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); 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") @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); 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") @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); 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"); throw badRequest("createdFrom must not be after createdTo");
} }
LambdaQueryWrapper<RelabelApplication> wrapper = new LambdaQueryWrapper<RelabelApplication>() 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.relabelNo()), RelabelApplication::getRelabelNo, query.relabelNo())
.like(StringUtils.hasText(query.customerOrderNo()), RelabelApplication::getCustomerOrderNo, query.customerOrderNo()) .like(StringUtils.hasText(query.customerOrderNo()), RelabelApplication::getCustomerOrderNo, query.customerOrderNo())
.eq(StringUtils.hasText(query.status()), RelabelApplication::getStatus, query.status()) .eq(StringUtils.hasText(query.status()), RelabelApplication::getStatus, query.status())
@@ -147,9 +147,10 @@ public class RelabelApplicationService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public RelabelResponses.Detail detail(String customerId, String relabelNo) { public RelabelResponses.Detail detail(String customerId, String relabelNo) {
RelabelApplication application = require(customerId, relabelNo); RelabelApplication application = require(customerId, relabelNo);
String recordCustomerId = application.getCustomerId();
List<RelabelItem> detailItems = itemList(application.getId()); List<RelabelItem> detailItems = itemList(application.getId());
List<RelabelStatusHistory> statusHistory = histories.selectList(new LambdaQueryWrapper<RelabelStatusHistory>() List<RelabelStatusHistory> statusHistory = histories.selectList(new LambdaQueryWrapper<RelabelStatusHistory>()
.eq(RelabelStatusHistory::getCustomerId, customerId) .eq(RelabelStatusHistory::getCustomerId, recordCustomerId)
.eq(RelabelStatusHistory::getApplicationId, application.getId()) .eq(RelabelStatusHistory::getApplicationId, application.getId())
.orderByAsc(RelabelStatusHistory::getCreatedAt)); .orderByAsc(RelabelStatusHistory::getCreatedAt));
return new RelabelResponses.Detail(summary(application, detailItems), application.getRemark(), return new RelabelResponses.Detail(summary(application, detailItems), application.getRemark(),
@@ -161,8 +162,11 @@ public class RelabelApplicationService {
@Transactional @Transactional
public RelabelResponses.Detail changeStatus(String customerId, String relabelNo, String operatorId, public RelabelResponses.Detail changeStatus(String customerId, String relabelNo, String operatorId,
RelabelRequests.ChangeStatus request) { 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"); if (application == null) throw notFound("Relabel application not found");
customerId = application.getCustomerId();
String next = request.status().trim().toUpperCase(); String next = request.status().trim().toUpperCase();
if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status"); if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status");
if (next.equals(application.getStatus())) return detail(customerId, relabelNo); if (next.equals(application.getStatus())) return detail(customerId, relabelNo);
@@ -232,7 +236,9 @@ public class RelabelApplicationService {
.orderByAsc(RelabelItem::getId)); .orderByAsc(RelabelItem::getId));
} }
private RelabelApplication require(String customerId, String relabelNo) { 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"); if (value == null) throw notFound("Relabel application not found");
return value; return value;
} }
@@ -62,7 +62,8 @@ public class LastlegOrderService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one order is required"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one order is required");
} }
List<LastlegOrder> orders = orderMapper.selectList(new LambdaQueryWrapper<LastlegOrder>() 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()) { if (orders.size() != ids.size()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more tail-leg orders were not found"); 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"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo");
} }
return new LambdaQueryWrapper<LastlegOrder>() 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.orderNo()), LastlegOrder::getOrderNo, query.orderNo())
.like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode()) .like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode())
.like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo()) .like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo())
@@ -139,7 +140,8 @@ public class LastlegOrderService {
private LastlegOrder findOwned(String customerId, long orderId) { private LastlegOrder findOwned(String customerId, long orderId) {
LastlegOrder order = orderMapper.selectOne(new LambdaQueryWrapper<LastlegOrder>() 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"); if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found");
return order; return order;
} }
@@ -153,7 +155,7 @@ public class LastlegOrderService {
private LastlegOrderSummary summary(LastlegOrder o) { private LastlegOrderSummary summary(LastlegOrder o) {
return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(), o.getSourceInboundId(), 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.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(), o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(),
o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(), o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
@@ -340,14 +340,14 @@ public class WarehouseTransferService {
for (WarehouseTransferInventoryAllocation allocation : allocations) { for (WarehouseTransferInventoryAllocation allocation : allocations) {
if (!"RESERVED".equals(allocation.getStatus())) throw conflict("Warehouse transfer allocation was already processed"); if (!"RESERVED".equals(allocation.getStatus())) throw conflict("Warehouse transfer allocation was already processed");
String keyBase = "WAREHOUSE_TRANSFER:" + transferNo + ":" + allocation.getInventoryPositionId(); 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, allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo,
"WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":ALLOCATE", trim(request.remark())); "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":ALLOCATE", trim(request.remark()));
inventory.allocate(customerId, operatorId, move); 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, allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo,
"WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":PICK", trim(request.remark()))); "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, allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo,
"WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":SHIP", trim(request.remark()))); "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":SHIP", trim(request.remark())));
allocation.setStatus("SHIPPED"); allocation.setActivePositionKey(null); allocation.setStatus("SHIPPED"); allocation.setActivePositionKey(null);
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS ll_order (
id BIGINT NOT NULL COMMENT '雪花 ID', id BIGINT NOT NULL COMMENT '雪花 ID',
order_no VARCHAR(32) NOT NULL COMMENT '尾程单号', order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
customer_id VARCHAR(128) 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 '入仓码', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统', source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统',
source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键', 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_order_no VARCHAR(128) COMMENT '换标单号',
relabel_status VARCHAR(32) COMMENT '换标状态', relabel_status VARCHAR(32) COMMENT '换标状态',
pallet_merge_flag 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 '子单据状态汇总', sub_order_status VARCHAR(255) NULL COMMENT '子单据状态汇总',
private_remark VARCHAR(1000) COMMENT '备注(仅自己)', private_remark VARCHAR(1000) COMMENT '备注(仅自己)',
customer_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]*$' } } - { 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 } } } } } } 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: 非法状态迁移 } } 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: components:
schemas: 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] } ProcurementStatus: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] }
ProcurementCreate: ProcurementCreate:
type: object type: object
@@ -9,14 +9,15 @@ import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.service.LastlegOrderService; import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.service.LoadingCompletedConsumer; import com.baiyue.lastleg.service.LoadingCompletedConsumer;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.Set; 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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -27,17 +28,15 @@ class LastlegOrderControllerTest {
1, 20, null, null, null, null, null, null, null, null, null, null); 1, 20, null, null, null, null, null, null, null, null, null, null);
@Test @Test
void customerCannotOverrideItsTenantScope() { void platformEndpointRejectsMerchantIdentity() {
ApiResponse<PageResult<LastlegOrderSummary>> response = assertThrows(ResponseStatusException.class,
controller.list(user(100L, "MERCHANT"), "200", query); () -> controller.list(user(100L, "MERCHANT"), "200", query));
assertNotNull(response);
verify(service).page(eq("100"), eq(query));
} }
@Test @Test
void platformOperatorMustExplicitlyChooseCustomerTenant() { void platformOperatorCanQueryAllCustomerTenantsWithoutCustomerId() {
assertThrows(ResponseStatusException.class, controller.list(user(0L, "PLATFORM"), null, query);
() -> controller.list(user(0L, "PLATFORM"), null, query)); verify(service).page(isNull(), eq(query));
} }
@Test @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.delivery.controller.DeliveryTransferController;
import com.baiyue.lastleg.dispatch.controller.DispatchController; 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.dispatch.pda.controller.DispatchPdaController;
import com.baiyue.lastleg.inboundplan.pda.controller.InboundPalletAssignmentController; import com.baiyue.lastleg.inboundplan.pda.controller.InboundPalletAssignmentController;
import com.baiyue.lastleg.inboundplan.pda.controller.InboundPdaController; 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.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.GetMapping;
import java.util.Arrays; import java.util.Arrays;
import java.util.Set; 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 @Test
void platformInventoryCommandsDoNotMapOutboundAllocationPickingOrShipping() { void platformInventoryCommandsDoNotMapOutboundAllocationPickingOrShipping() {
Set<String> paths = Arrays.stream(InventoryCommandController.class.getDeclaredMethods()) 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)); 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) { private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) {
return new DispatchPalletCandidate(1L, planPalletId, warehousePalletId, 2L, "WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1", return new DispatchPalletCandidate(1L, planPalletId, warehousePalletId, 2L, "WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1",
"LIP1-P001", destination, destination, destination, destination, 12, "LIP1-P001", destination, destination, destination, destination, 12,
@@ -36,18 +36,14 @@ class InboundPlanControllerTest {
} }
@Test @Test
void platformPageRequiresAndPassesExplicitCustomerId() { void platformPageAllowsAllCustomersWhenCustomerIdIsOmitted() {
InboundPlanRequests.Query query = new InboundPlanRequests.Query(1, 20, null, null, null, InboundPlanRequests.Query query = new InboundPlanRequests.Query(1, 20, null, null, null,
null, null, null, null, null, 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)); 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); verify(service).page(null, query);
assertThatThrownBy(() -> controller.page(platform(), "", query))
.isInstanceOf(ResponseStatusException.class)
.satisfies(error -> org.assertj.core.api.Assertions.assertThat(
((ResponseStatusException) error).getStatusCode().value()).isEqualTo(400));
} }
@Test @Test
@@ -97,7 +97,7 @@ class InventoryCommandServiceTest {
assertThrows(ResponseStatusException.class, assertThrows(ResponseStatusException.class,
() -> service.hold("100", "7", new InventoryCommandRequests.Move( () -> 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(positions, never()).updateById(any(InventoryPosition.class));
verify(balances, never()).updateById(any(InventoryBalance.class)); verify(balances, never()).updateById(any(InventoryBalance.class));
} }
@@ -129,7 +129,7 @@ class InventoryCommandServiceTest {
when(commands.palletPackages("100", 300L)).thenReturn(0L); when(commands.palletPackages("100", 300L)).thenReturn(0L);
var result = service.ship("100", "7", new InventoryCommandRequests.Move( 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()); assertTrue(result.palletEmpty());
assertEquals("EMPTY", position.getStatus()); assertEquals("EMPTY", position.getStatus());
@@ -137,6 +137,23 @@ class InventoryCommandServiceTest {
verify(ledgers).insert(any(InventoryLedger.class)); 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) { private InventoryCommandRequests.StockIn stockIn(String inbound, String key, long packages, long quantity) {
return new InventoryCommandRequests.StockIn("100", "Customer 100", "WH-1", "WH1", "Warehouse 1", 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, "DEST-1", "ONT8", "ONT8", 101L, inbound, "IN-CODE-1", "SKU-1", "Product 1", null,
@@ -44,14 +44,14 @@ class InventoryCommandControllerTest {
@Test @Test
void permittedBackOfficeUserCanRunInventoryFormCommand() { void permittedBackOfficeUserCanRunInventoryFormCommand() {
var request = move(); 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); controller.hold(user(Set.of(InventoryCommandController.MANAGE_PERMISSION)), request);
verify(service).hold("100", "7", request); verify(service).holdPlatform("7", request);
} }
@Test @Test
void physicalAdjustmentRequiresDedicatedPermission() { 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, ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> controller.adjust(user(Set.of(InventoryCommandController.MANAGE_PERMISSION)), request)); () -> controller.adjust(user(Set.of(InventoryCommandController.MANAGE_PERMISSION)), request));
assertEquals(403, exception.getStatusCode().value()); assertEquals(403, exception.getStatusCode().value());
@@ -59,11 +59,11 @@ class InventoryCommandControllerTest {
} }
private InventoryCommandRequests.Move move() { 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) { 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() { private InventoryCommandResponse response() {
@@ -21,21 +21,21 @@ class InventoryQueryControllerTest {
1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null); 1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null);
@Test @Test
void merchantCannotOverrideCustomerScope() { void platformBalancePageQueriesAllCustomers() {
controller.balances(user(100L, "MERCHANT"), "200", query); controller.balances(user(0L, "PLATFORM"), query);
verify(service).balances(eq("100"), eq(query)); verify(service).balances(eq(null), eq(query));
} }
@Test @Test
void platformMustSelectCustomer() { void merchantCannotUsePlatformInventoryController() {
assertThrows(ResponseStatusException.class, assertThrows(ResponseStatusException.class,
() -> controller.summary(user(0L, "PLATFORM"), null, query)); () -> controller.summary(user(100L, "MERCHANT"), query));
} }
@Test @Test
void platformCanQuerySelectedCustomer() { void platformSummaryQueriesAllCustomers() {
controller.summary(user(0L, "PLATFORM"), "200", query); controller.summary(user(0L, "PLATFORM"), query);
verify(service).dashboard(eq("200"), eq(query)); verify(service).dashboard(eq(null), eq(query));
} }
@Test @Test
@@ -43,7 +43,7 @@ class InventoryQueryControllerTest {
InventoryQueryRequests.PositionQuery positionQuery = InventoryQueryRequests.PositionQuery positionQuery =
new InventoryQueryRequests.PositionQuery(null, null, null); new InventoryQueryRequests.PositionQuery(null, null, null);
assertThrows(ResponseStatusException.class, assertThrows(ResponseStatusException.class,
() -> controller.positions(null, null, 1L, positionQuery)); () -> controller.positions(null, 1L, positionQuery));
} }
private static AuthenticatedUser user(long customerId, String appCode) { private static AuthenticatedUser user(long customerId, String appCode) {
@@ -65,6 +65,16 @@ class InventoryQueryServiceTest {
verify(mapper).balances("100", query, 0, 20); 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 @Test
void unknownStatusIsRejectedBeforeDatabaseQuery() { void unknownStatusIsRejectedBeforeDatabaseQuery() {
InventoryQueryRequests.BalanceQuery query = balanceQuery("DAMAGED"); InventoryQueryRequests.BalanceQuery query = balanceQuery("DAMAGED");
@@ -102,6 +112,20 @@ class InventoryQueryServiceTest {
assertEquals(7L, result.totalPackages()); 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 @Test
void reversedLedgerDateRangeIsRejected() { void reversedLedgerDateRangeIsRejected() {
InventoryQueryRequests.LedgerQuery query = new InventoryQueryRequests.LedgerQuery( 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")); 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); var response = service.detail("100", 11L);
assertEquals(11L, response.order().id()); assertEquals(11L, response.order().id());
assertEquals("100", response.order().customerId());
assertTrue(java.util.Arrays.stream(response.getClass().getRecordComponents()) assertTrue(java.util.Arrays.stream(response.getClass().getRecordComponents())
.noneMatch(component -> "dropshipPallets".equals(component.getName()))); .noneMatch(component -> "dropshipPallets".equals(component.getName())));
assertTrue(java.util.Arrays.stream(com.baiyue.lastleg.dto.LastlegOrderDetailResponse.History.class.getRecordComponents()) 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')); '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; 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'), 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 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')); '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( @Operation(
summary = "List tenant warehouses / 查询租户仓库", summary = "List warehouses / 查询仓库",
description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST + ". Returns a customer-scoped warehouse page with optional filters." description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST + ". Returns all warehouses with optional filters."
) )
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@@ -102,12 +102,9 @@ public class PlatformWarehouseController {
@PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "')")
public ApiResponse<PageResult<WarehouseEntity>> page( public ApiResponse<PageResult<WarehouseEntity>> page(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true)
@RequestParam @NotNull Long customerId,
@Valid WarehouseRequests.QueryRequest request) { @Valid WarehouseRequests.QueryRequest request) {
requirePlatform(user); requirePlatform(user);
requireTenant(customerId); return ApiResponse.success(service.pageAll(request));
return ApiResponse.success(service.page(customerId, request));
} }
@Operation( @Operation(
@@ -85,6 +85,29 @@ public class WarehouseRepository {
return PageResult.from(warehouses.selectPage(new Page<>(query.page(), query.size()), wrapper)); 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) { public List<WarehouseAddressEntity> findAddresses(long customerId, long warehouseId) {
return addresses.selectList(new LambdaQueryWrapper<WarehouseAddressEntity>() return addresses.selectList(new LambdaQueryWrapper<WarehouseAddressEntity>()
.eq(WarehouseAddressEntity::getCustomerId, customerId) .eq(WarehouseAddressEntity::getCustomerId, customerId)
@@ -87,6 +87,10 @@ public class WarehouseApplicationService {
return repository.page(customerId, query); return repository.page(customerId, query);
} }
public PageResult<WarehouseEntity> pageAll(WarehouseRequests.QueryRequest query) {
return repository.pageAll(query);
}
public WarehouseDetailResponse detail(long customerId, long id) { public WarehouseDetailResponse detail(long customerId, long id) {
WarehouseEntity warehouse = owned(customerId, id); WarehouseEntity warehouse = owned(customerId, id);
List<WarehouseAddressEntity> addresses = repository.findAddresses(customerId, id); List<WarehouseAddressEntity> addresses = repository.findAddresses(customerId, id);
@@ -18,17 +18,17 @@ spring:
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
cloud: cloud:
nacos: 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} username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos} password: ${NACOS_PASSWORD:nacos}
discovery: 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} enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE} group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public} namespace: ${NACOS_NAMESPACE:public}
config: 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} namespace: ${NACOS_NAMESPACE:public}
datasource: datasource:
driver-class-name: com.mysql.cj.jdbc.Driver 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.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class WarehouseApplicationServiceTest { class WarehouseApplicationServiceTest {
@@ -48,6 +49,16 @@ class WarehouseApplicationServiceTest {
assertThat(location.getValue().getStatus()).isEqualTo("ACTIVE"); 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() { private static WarehouseRequests.SaveRequest request() {
WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest( WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest(
"WAREHOUSE", "US", "CA", "Los Angeles", null, "90001", "WAREHOUSE", "US", "CA", "Los Angeles", null, "90001",