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