fix: bind overseas receiving to shared warehouse

This commit is contained in:
2026-08-24 21:46:19 +08:00
parent 3607bebbfd
commit 8cc4c6f6da
61 changed files with 774 additions and 118 deletions
@@ -24,6 +24,8 @@ public class LastlegOrder {
private String sourceOrderNo;
private String rootOrderId;
private String customerName;
private String sourceWarehouseId;
private String sourceWarehouseName;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String fbaWarehouseCode;
@@ -17,6 +17,7 @@ public class InboundPlan {
private String customerId;
private Long sourceOrderId;
private String containerNo;
private Long overseasWarehouseId;
private String status;
private Integer lineCount;
private Integer palletCount;
@@ -7,6 +7,7 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@@ -47,4 +48,10 @@ public interface InboundPlanMapper extends BaseMapper<InboundPlan> {
@Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND source_order_id=#{sourceOrderId} LIMIT 1")
InboundPlan findBySourceOrder(@Param("customerId") String customerId, @Param("sourceOrderId") Long sourceOrderId);
@Update("UPDATE ll_inbound_plan SET overseas_warehouse_id=#{warehouseId},updated_at=NOW(3) " +
"WHERE id=#{planId} AND customer_id=#{customerId} " +
"AND (overseas_warehouse_id IS NULL OR overseas_warehouse_id=#{warehouseId})")
int claimReceivingWarehouse(@Param("customerId") String customerId, @Param("planId") long planId,
@Param("warehouseId") long warehouseId);
}
@@ -38,7 +38,7 @@ public class ReceivingContainerController {
public ApiResponse<List<ReceivingContainerResponses.Candidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user) {
Identity identity = identity(user);
return ApiResponse.success(service.candidates(identity.operatorKey()));
return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId()));
}
@GetMapping("/{containerNo}")
@@ -46,21 +46,21 @@ public class ReceivingContainerController {
public ApiResponse<ReceivingContainerResponses.Session> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String containerNo) {
Identity identity = identity(user);
return ApiResponse.success(service.detail(identity.operatorKey(), containerNo));
return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), containerNo));
}
@PostMapping("/{containerNo}/prepare")
public ApiResponse<ReceivingContainerResponses.Session> prepare(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) {
Identity identity = identity(user);
return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(),
return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), identity.overseasWarehouseId(),
containerNo, request));
}
@PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<ReceivingContainerResponses.Photo> uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@RequestPart("file") MultipartFile file) {
Identity identity = identity(user);
Identity identity = currentIdentity(user);
if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required");
return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file));
}
@@ -68,13 +68,13 @@ public class ReceivingContainerController {
@PostMapping("/current/sign")
public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.Sign request) {
Identity identity = identity(user);
Identity identity = currentIdentity(user);
return ApiResponse.success(service.sign(identity.operatorKey(), identity.operatorName(), request));
}
@GetMapping("/current")
public ApiResponse<ReceivingContainerResponses.Session> current(@AuthenticationPrincipal AuthenticatedUser user) {
Identity identity = identity(user);
Identity identity = currentIdentity(user);
return ApiResponse.success(service.current(identity.operatorKey()));
}
@@ -82,7 +82,7 @@ public class ReceivingContainerController {
public ApiResponse<ReceivingContainerResponses.CartonResolved> resolveCarton(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.CartonResolve request) {
Identity identity = identity(user);
Identity identity = currentIdentity(user);
return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request));
}
@@ -90,7 +90,7 @@ public class ReceivingContainerController {
public ApiResponse<ReceivingContainerResponses.SkuResolved> resolveSku(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.SkuResolve request) {
Identity identity = identity(user);
Identity identity = currentIdentity(user);
return ApiResponse.success(service.resolveSku(identity.operatorKey(), request));
}
@@ -98,28 +98,28 @@ public class ReceivingContainerController {
public ApiResponse<ReceivingContainerResponses.PalletResolved> resolvePallet(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.PalletResolve request) {
Identity identity = identity(user);
Identity identity = currentIdentity(user);
return ApiResponse.success(service.resolvePallet(identity.operatorKey(), request));
}
@PostMapping("/current/receipts/bulk")
public ApiResponse<ReceivingContainerResponses.Receipt> receiveBulk(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.BulkReceive request) {
Identity identity = identity(user);
Identity identity = currentIdentity(user);
return ApiResponse.success(service.receiveBulk(identity.operatorKey(), identity.operatorName(), request));
}
@PostMapping("/current/receipts/carton")
public ApiResponse<ReceivingContainerResponses.Receipt> receiveCarton(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.CartonReceive request) {
Identity identity = identity(user);
Identity identity = currentIdentity(user);
return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request));
}
@PostMapping("/current/complete")
public ApiResponse<ReceivingContainerResponses.Session> complete(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.Action request) {
Identity identity = identity(user);
Identity identity = currentIdentity(user);
return ApiResponse.success(service.complete(identity.operatorKey(), request));
}
@@ -131,10 +131,20 @@ public class ReceivingContainerController {
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
}
if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "PDA operator must be bound to one overseas warehouse");
}
String operator = String.valueOf(user.userId());
return new Identity(operator,
user.username() == null || user.username().isBlank() ? operator : user.username());
user.username() == null || user.username().isBlank() ? operator : user.username(),
user.overseasWarehouseId());
}
private record Identity(String operatorKey, String operatorName) { }
private Identity currentIdentity(AuthenticatedUser user) {
Identity identity = identity(user);
service.requireCurrentWarehouse(identity.operatorKey(), identity.overseasWarehouseId());
return identity;
}
private record Identity(String operatorKey, String operatorName, long overseasWarehouseId) { }
}
@@ -10,7 +10,8 @@ public final class ReceivingContainerResponses {
List<String> destinationCodes, int plannedPalletCount,
int totalPackages, int receivedPackages, boolean currentAccount) { }
public record Session(String containerNo, String status, String operatorName, int version,
public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName,
String status, String operatorName, int version,
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
LocalDateTime receivingAt, LocalDateTime completedAt,
int totalPackages, int receivedPackages, int pendingPackages,
@@ -18,19 +19,23 @@ public final class ReceivingContainerResponses {
public record SignedPhoto(ReceivingPhotoRole role, long fileId) { }
public record Inbound(String inboundCode, String destinationType, String destinationWarehouseId,
public record Inbound(String inboundCode, String destinationType,
String overseasWarehouseId, String overseasWarehouseName,
String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages) { }
public record CartonResolved(String cartonMark, String skuCode, String inboundCode,
String destinationType, String destinationWarehouseId,
String destinationType, String overseasWarehouseId,
String overseasWarehouseName, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages) { }
public record SkuResolved(String skuCode, String productName, String inboundCode,
String destinationType, String destinationWarehouseId,
String destinationType, String overseasWarehouseId,
String overseasWarehouseName, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages) { }
@@ -15,6 +15,8 @@ public class ReceivingContainerSession {
private Long id;
private String customerId;
private String containerNo;
private Long overseasWarehouseId;
private String overseasWarehouseName;
private String operatorKey;
private String operatorName;
private String activeOperatorKey;
@@ -4,12 +4,14 @@ import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
@@ -42,10 +44,14 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
.header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", String.valueOf(operatorId))
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Unload photo upload failed");
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"Unload photo upload failed");
}).body(MetadataEnvelope.class);
requireSuccess(response, "Unload photo upload failed");
Metadata data = response == null ? null : response.data();
if (data == null || data.fileId() == null) throw new IllegalArgumentException("File service returned no photo metadata");
if (data == null || data.fileId() == null) throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "File service returned no photo metadata");
return new UploadedFile(data.fileId(), data.fileName(), data.contentType(),
data.sizeBytes() == null ? 0 : data.sizeBytes());
}
@@ -58,22 +64,40 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
tenantId, BUSINESS_TYPE, taskId, fileId)
.header("X-Internal-Service-Token", internalToken)
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Unload photo does not exist or its ownership is invalid");
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"Unload photo does not exist or its ownership is invalid");
}).body(MetadataEnvelope.class);
requireSuccess(response, "Unload photo does not exist or its ownership is invalid");
Metadata data = response == null ? null : response.data();
if (data == null || data.fileId() == null || data.fileId() != fileId
|| data.tenantId() == null || data.tenantId() != tenantId
|| !BUSINESS_TYPE.equals(data.businessType()) || !String.valueOf(taskId).equals(data.businessId())) {
throw new IllegalArgumentException("Unload photo metadata does not match the PDA task");
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Unload photo metadata does not match the PDA task");
}
}
private long tenantId(String customerId) {
try { return Long.parseLong(customerId); }
catch (NumberFormatException exception) { throw new IllegalArgumentException("Customer ID must be numeric", exception); }
catch (NumberFormatException exception) { throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Customer ID must be numeric", exception); }
}
private record MetadataEnvelope(Metadata data) {}
private static void requireSuccess(MetadataEnvelope response, String fallback) {
if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback);
if ("200".equals(response.code())) return;
HttpStatus status = switch (String.valueOf(response.code())) {
case "A0400", "A0422" -> HttpStatus.BAD_REQUEST;
case "A0403" -> HttpStatus.FORBIDDEN;
case "A0404" -> HttpStatus.NOT_FOUND;
default -> HttpStatus.SERVICE_UNAVAILABLE;
};
throw new ResponseStatusException(status,
response.message() == null || response.message().isBlank() ? fallback : response.message());
}
private record MetadataEnvelope(String code, String message, Metadata data) {}
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes) {}
}
@@ -2,8 +2,10 @@ package com.baiyue.lastleg.inboundplan.pda.gateway;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
@@ -111,9 +113,37 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
response.data().fbaWarehouseCode(), response.data().version());
}
@Override
public WarehouseReference requireActiveSharedWarehouse(long warehouseId) {
WarehouseReferenceResponse response = client.get()
.uri("/internal/v1/warehouse-resource-locks/shared-warehouses/{warehouseId}", warehouseId)
.header("X-Internal-Service-Token", internalToken)
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw new ResponseStatusException(result.getStatusCode().value() == 404
? HttpStatus.NOT_FOUND : (result.getStatusCode().is4xxClientError()
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE),
"Shared overseas warehouse lookup failed");
}).body(WarehouseReferenceResponse.class);
if (response == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Shared overseas warehouse service returned no response");
}
if ("A0404".equals(response.code())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"Active shared overseas warehouse was not found");
}
if (!"200".equals(response.code()) || response.data() == null
|| response.data().warehouseId() != warehouseId || !"ACTIVE".equals(response.data().status())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Invalid shared overseas warehouse binding");
}
return response.data();
}
private record WarehousePalletResponse(WarehousePalletData data) {}
private record WarehousePalletData(long id, String palletCode, long warehouseId, long locationId,
String status, String fbaWarehouseCode, int version) {}
private record ReserveEnvelope(ReserveData data) {}
private record ReserveData(long lockId) {}
private record WarehouseReferenceResponse(String code, String message, WarehouseReference data) {}
}
@@ -8,6 +8,9 @@ public interface WarehouseResourceGateway {
void scrap(String customerId, long resourceLockId);
void scrapPallet(String customerId, long palletId);
WarehousePalletReference resolvePallet(String palletCode);
default WarehouseReference requireActiveSharedWarehouse(long warehouseId) {
throw new UnsupportedOperationException("Shared warehouse resolution is not configured");
}
record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId,
String status, String fbaWarehouseCode, int version) {
@@ -15,4 +18,7 @@ public interface WarehouseResourceGateway {
this(palletId, palletNo, 0L, locationId, null, null, 0);
}
}
record WarehouseReference(long warehouseId, String warehouseCode, String warehouseName,
String status) { }
}
@@ -80,8 +80,9 @@ public class ReceivingContainerService {
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
}
public List<ReceivingContainerResponses.Candidate> candidates(String operatorKey) {
public List<ReceivingContainerResponses.Candidate> candidates(String operatorKey, long overseasWarehouseId) {
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
requireSessionWarehouse(current, overseasWarehouseId);
Map<String, List<InboundPlan>> grouped = new LinkedHashMap<>();
plans.selectList(new LambdaQueryWrapper<InboundPlan>()
.ne(InboundPlan::getStatus, "COMPLETED").orderByAsc(InboundPlan::getCreatedAt)).forEach(plan -> {
@@ -89,7 +90,7 @@ public class ReceivingContainerService {
grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan);
}
});
return grouped.entrySet().stream().map(entry -> {
return grouped.entrySet().stream().filter(entry -> eligible(entry.getValue(), overseasWarehouseId)).map(entry -> {
requireSingleCustomer(entry.getValue(), entry.getKey());
Totals totals = totals(entry.getValue());
boolean active = current != null && entry.getKey().equals(current.getContainerNo());
@@ -99,27 +100,39 @@ public class ReceivingContainerService {
}).toList();
}
public ReceivingContainerResponses.Session detail(String operatorKey, String containerNo) {
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) {
List<InboundPlan> containerPlans = requirePlans(containerNo);
String customerId = requireSingleCustomer(containerPlans, containerNo);
if (!eligible(containerPlans, overseasWarehouseId)) throw notFound("Container is assigned to another overseas warehouse");
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
requireSessionWarehouse(current, overseasWarehouseId);
if (current != null && !containerNo.equals(current.getContainerNo())) {
throw conflict("Current account is locked to another selected container");
}
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView(
customerId, operatorKey, containerNo), containerPlans);
customerId, operatorKey, receivingWarehouse, containerNo), containerPlans);
}
@Transactional
public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, String containerNo,
public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, long overseasWarehouseId, String containerNo,
ReceivingContainerRequests.Action request) {
PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey());
ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command);
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
if (replay != null) {
ReceivingContainerResponses.Session result = read(replay, ReceivingContainerResponses.Session.class);
if (!Objects.equals(result.overseasWarehouseId(), overseasWarehouseId)) {
throw conflict("Prepared container belongs to another overseas warehouse");
}
return result;
}
List<InboundPlan> containerPlans = requirePlans(containerNo);
String customerId = requireSingleCustomer(containerPlans, containerNo);
if (!eligible(containerPlans, overseasWarehouseId)) throw conflict("Container is assigned to another overseas warehouse");
String activeOperatorKey = activeOperatorKey(operatorKey);
ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey);
requireSessionWarehouse(active, overseasWarehouseId);
if (active != null) {
if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one");
ReceivingContainerResponses.Session result = snapshot(active, containerPlans);
@@ -128,9 +141,14 @@ public class ReceivingContainerService {
if (sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) {
throw conflict("Container is already signed or prepared by another account");
}
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
claimReceivingWarehouse(customerId, containerPlans, receivingWarehouse);
LocalDateTime now = LocalDateTime.now();
ReceivingContainerSession session = new ReceivingContainerSession();
session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo);
session.setOverseasWarehouseId(overseasWarehouseId);
session.setOverseasWarehouseName(receivingWarehouse.warehouseName());
session.setOperatorKey(operatorKey); session.setOperatorName(operatorName);
session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(containerNo));
session.setStatus("WAIT_SIGN"); session.setPreparedAt(now); session.setCreatedAt(now); session.setUpdatedAt(now);
@@ -175,6 +193,10 @@ public class ReceivingContainerService {
return snapshot(session, requirePlans(session.getCustomerId(), session.getContainerNo()));
}
public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) {
requireSessionWarehouse(sessions.findActiveByOperator(activeOperatorKey(operatorKey)), overseasWarehouseId);
}
public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey,
ReceivingContainerRequests.CartonResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey);
@@ -190,7 +212,8 @@ public class ReceivingContainerService {
}
ReceivingContainerResponses.Inbound inbound = inbound(ref);
return new ReceivingContainerResponses.CartonResolved(carton.getCartonMark(), carton.getSkuCode(),
inbound.inboundCode(), inbound.destinationType(), inbound.destinationWarehouseId(),
inbound.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(),
inbound.overseasWarehouseName(), inbound.destinationWarehouseId(),
inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(),
inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages());
}
@@ -403,7 +426,8 @@ public class ReceivingContainerService {
long actualWarehouse;
try { actualWarehouse = Long.parseLong(ref.order().getOverseasWarehouseId()); }
catch (Exception exception) { throw conflict("Inbound order has no valid receiving warehouse ID"); }
if (pallet.warehouseId() != actualWarehouse) throw conflict("Pallet belongs to another overseas warehouse");
long assignedWarehouse = ref.plan().getOverseasWarehouseId() == null ? actualWarehouse : ref.plan().getOverseasWarehouseId();
if (pallet.warehouseId() != assignedWarehouse) throw conflict("Pallet belongs to another overseas warehouse");
String type = destinationType(ref.line(), ref.order());
if ("FBA".equals(type)) {
if (ref.line().getFbaWarehouseCode() == null || ref.line().getFbaWarehouseCode().isBlank()
@@ -438,7 +462,8 @@ public class ReceivingContainerService {
.map(this::inbound).toList();
int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum();
int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum();
return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getStatus(), session.getOperatorName(),
return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(),
session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(),
value(session.getVersion()), signPhotos(session), session.getPreparedAt(), session.getSignedAt(),
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds);
}
@@ -451,6 +476,8 @@ public class ReceivingContainerService {
.mapToInt(value -> value(value.getPickedPackageCount())).sum();
int total = value(ref.line().getRequiredPackageCount());
return new ReceivingContainerResponses.Inbound(ref.line().getInboundCode(), destinationType(ref.line(), ref.order()),
ref.plan().getOverseasWarehouseId() == null ? ref.order().getOverseasWarehouseId()
: String.valueOf(ref.plan().getOverseasWarehouseId()), ref.order().getOverseasWarehouseName(),
ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(),
ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(),
value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received));
@@ -517,7 +544,8 @@ public class ReceivingContainerService {
ReceivingContainerResponses.Inbound inbound = inbound(sku.ref());
int received = receivedSkuPackages(sku.ref().order().getCustomerId(), sku.ref().line().getId(), sku.skuCode());
return new ReceivingContainerResponses.SkuResolved(sku.skuCode(), sku.productName(), inbound.inboundCode(),
inbound.destinationType(), inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(),
inbound.destinationType(), inbound.overseasWarehouseId(), inbound.overseasWarehouseName(),
inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(),
inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), inbound.plannedPalletCount(),
sku.expectedPackages(), received, Math.max(0, sku.expectedPackages() - received));
}
@@ -600,12 +628,56 @@ public class ReceivingContainerService {
if (sessions.updateById(session) != 1) throw conflict("Receiving container session changed concurrently");
}
private ReceivingContainerSession waitingView(String customerId, String operatorKey, String containerNo) {
private ReceivingContainerSession waitingView(String customerId, String operatorKey,
WarehouseResourceGateway.WarehouseReference warehouse,
String containerNo) {
ReceivingContainerSession value = new ReceivingContainerSession(); value.setCustomerId(customerId);
value.setOverseasWarehouseId(warehouse.warehouseId());
value.setOverseasWarehouseName(warehouse.warehouseName());
value.setOperatorKey(operatorKey); value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0);
return value;
}
private boolean eligible(List<InboundPlan> containerPlans, long overseasWarehouseId) {
Set<Long> assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId)
.filter(Objects::nonNull).collect(Collectors.toSet());
if (assigned.size() > 1 || (!assigned.isEmpty() && containerPlans.stream()
.anyMatch(plan -> plan.getOverseasWarehouseId() == null))) {
throw conflict("Container plans have inconsistent receiving warehouse assignments");
}
return assigned.isEmpty() || assigned.contains(overseasWarehouseId);
}
private void claimReceivingWarehouse(String customerId, List<InboundPlan> containerPlans,
WarehouseResourceGateway.WarehouseReference warehouse) {
long overseasWarehouseId = warehouse.warehouseId();
String warehouseId = String.valueOf(overseasWarehouseId);
for (InboundPlan plan : containerPlans) {
int claimed = plans.claimReceivingWarehouse(customerId, plan.getId(), overseasWarehouseId);
InboundPlan actual = plans.selectById(plan.getId());
if ((claimed == 0 && actual == null) || actual == null
|| !Objects.equals(actual.getOverseasWarehouseId(), overseasWarehouseId)) {
throw conflict("Container was claimed by another overseas warehouse");
}
plan.setOverseasWarehouseId(overseasWarehouseId);
for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) {
orders.claimReceivingWarehouse(customerId, line.getOrderId(), warehouseId,
warehouse.warehouseName());
LastlegOrder order = orders.selectById(line.getOrderId());
if (order == null || !warehouseId.equals(order.getOverseasWarehouseId())) {
throw conflict("Inbound order was claimed by another overseas warehouse");
}
}
}
}
private void requireSessionWarehouse(ReceivingContainerSession session, long overseasWarehouseId) {
if (session != null && !Objects.equals(session.getOverseasWarehouseId(), overseasWarehouseId)) {
throw conflict("Current container belongs to another overseas warehouse");
}
}
private ReceivingContainerOperation replay(String operatorKey, String action, String key, Object request) {
key = idempotencyKey(key);
ReceivingContainerOperation value = operations.find(operatorKey, key);
@@ -22,6 +22,7 @@ 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;
@@ -40,9 +41,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) {
requirePlatform(user);
return ApiResponse.success(service.dashboard(null, query));
return ApiResponse.success(service.dashboard(platformScope(user, customerId), query));
}
@GetMapping(BASE_PATH + "/balances")
@@ -50,9 +51,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) {
requirePlatform(user);
return ApiResponse.success(service.balances(null, query));
return ApiResponse.success(service.balances(platformScope(user, customerId), query));
}
@GetMapping(BASE_PATH + "/balances/{balanceId}/positions")
@@ -61,9 +62,9 @@ public class InventoryQueryController {
public ApiResponse<List<InventoryPositionView>> positions(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long balanceId,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.PositionQuery query) {
requirePlatform(user);
return ApiResponse.success(service.positions(null, balanceId, query));
return ApiResponse.success(service.positions(platformScope(user, customerId), balanceId, query));
}
@GetMapping(BASE_PATH + "/ledger")
@@ -71,9 +72,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) {
requirePlatform(user);
return ApiResponse.success(service.ledgers(null, query));
return ApiResponse.success(service.ledgers(platformScope(user, customerId), query));
}
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
@@ -81,9 +82,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) {
requirePlatform(user);
return csv(service.exportBalances(null, query), "inventory-balances.csv");
return csv(service.exportBalances(platformScope(user, customerId), query), "inventory-balances.csv");
}
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv")
@@ -91,9 +92,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) {
requirePlatform(user);
return csv(service.exportLedgers(null, query), "inventory-ledger.csv");
return csv(service.exportLedgers(platformScope(user, customerId), query), "inventory-ledger.csv");
}
private static ResponseEntity<byte[]> csv(byte[] body, String filename) {
@@ -103,12 +104,14 @@ public class InventoryQueryController {
.body(body);
}
private void requirePlatform(AuthenticatedUser user) {
private String platformScope(AuthenticatedUser user, String requestedCustomerId) {
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");
}
return requestedCustomerId == null || requestedCustomerId.isBlank() || "0".equals(requestedCustomerId.trim())
? null : requestedCustomerId.trim();
}
}
@@ -89,8 +89,6 @@ public interface InventoryQueryMapper {
@Select("<script>SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId}" +
"<if test='q.palletNo != null and q.palletNo != \"\"'> AND pallet_no LIKE CONCAT('%',#{q.palletNo},'%')</if>" +
"<if test='q.batchNo != null and q.batchNo != \"\"'> AND batch_no LIKE CONCAT('%',#{q.batchNo},'%')</if>" +
"<if test='q.skuCode != null and q.skuCode != \"\"'> AND sku_code LIKE CONCAT('%',#{q.skuCode},'%')</if>" +
"<if test='q.warehouseLocationCode != null and q.warehouseLocationCode != \"\"'> AND warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%')</if>" +
"<choose>" +
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND (available_packages+allocated_packages+picked_packages) &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages &gt; 0</when>" +
@@ -35,4 +35,11 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
"AND shipped_package_count+#{packages} <= package_count")
int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id,
@Param("packages") long packages, @Param("outboundNo") String outboundNo);
@Update("UPDATE ll_order SET overseas_warehouse_id=#{warehouseId},overseas_warehouse_name=#{warehouseName}," +
"updated_at=NOW(3) WHERE id=#{orderId} AND customer_id=#{customerId} " +
"AND (overseas_warehouse_id IS NULL OR overseas_warehouse_id=#{warehouseId})")
int claimReceivingWarehouse(@Param("customerId") String customerId, @Param("orderId") long orderId,
@Param("warehouseId") String warehouseId,
@Param("warehouseName") String warehouseName);
}
@@ -170,7 +170,8 @@ public class LoadingCompletedConsumer {
order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name());
order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId());
order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId());
order.setOverseasWarehouseId(payload.warehouseId()); order.setOverseasWarehouseName(payload.warehouseName());
order.setSourceWarehouseId(payload.warehouseId()); order.setSourceWarehouseName(payload.warehouseName());
order.setOverseasWarehouseId(null); order.setOverseasWarehouseName(null);
order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(payload.containerNo());
order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false);
order.setPackageCount(source.packageCount()); order.setPalletCount(dropshipPallets.size());
@@ -32,7 +32,9 @@ CREATE TABLE IF NOT EXISTS ll_order (
source_order_no VARCHAR(128) COMMENT '来源订单号',
root_order_id VARCHAR(128) COMMENT '根订单 ID',
customer_name VARCHAR(255) COMMENT '货主名称',
overseas_warehouse_id VARCHAR(128) COMMENT '海外仓 ID',
source_warehouse_id VARCHAR(128) COMMENT '来源国内装柜仓 ID 快照',
source_warehouse_name VARCHAR(255) COMMENT '来源国内装柜仓名称快照',
overseas_warehouse_id VARCHAR(128) COMMENT '海外实际收货仓 ID,由绑定仓库的PDA操作员认领柜号时冻结',
overseas_warehouse_name VARCHAR(255) COMMENT '海外仓名称',
transfer_warehouse_id VARCHAR(128) COMMENT '转入目的仓 ID',
transfer_warehouse_code VARCHAR(128) COMMENT '转入目的仓编码',
@@ -352,6 +354,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan (
customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识',
source_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
overseas_warehouse_id BIGINT NULL COMMENT '海外实际收货共享物理仓ID,由PDA认领柜号时冻结',
status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态',
line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量',
pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量',
@@ -679,7 +682,7 @@ WHERE (l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL)
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间',
@@ -470,6 +470,8 @@ components:
properties:
inboundCode: { type: string }
destinationType: { type: string, enum: [FBA, OVERSEAS] }
overseasWarehouseId: { type: string, nullable: true, description: 实际海外收货仓ID;与FBA或其他业务目的仓ID严格区分 }
overseasWarehouseName: { type: string, nullable: true, description: 实际海外收货仓名称快照 }
destinationWarehouseId: { type: string, nullable: true }
destinationWarehouseCode: { type: string, nullable: true }
destinationWarehouseName: { type: string, nullable: true }
@@ -493,9 +495,11 @@ components:
currentAccount: { type: boolean }
ReceivingSession:
type: object
required: [containerNo, status, version, photos, totalPackages, receivedPackages, pendingPackages, inbounds]
required: [containerNo, overseasWarehouseId, overseasWarehouseName, status, version, photos, totalPackages, receivedPackages, pendingPackages, inbounds]
properties:
containerNo: { type: string }
overseasWarehouseId: { type: string, pattern: '^\d+$', description: PDA Token绑定并在认领柜号时冻结的共享实际海外收货仓ID }
overseasWarehouseName: { type: string, description: Warehouse Overseas服务校验后的共享实际海外收货仓名称快照 }
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] }
operatorName: { type: string, nullable: true }
version: { type: integer }
@@ -70,6 +70,8 @@ class ReceivingContainerContractTest {
assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)"));
String receivingSession = tableBlock(schema, "ll_receiving_container_session");
assertTrue(receivingSession.contains("sign_photos json"));
assertTrue(receivingSession.contains("overseas_warehouse_id bigint not null"));
assertTrue(receivingSession.contains("overseas_warehouse_name varchar(255) not null"));
assertFalse(receivingSession.contains("photo_file_ids json"));
assertTrue(unified.contains("create table if not exists ll_receiving_sku_receipt"));
assertTrue(unified.contains("column_name='sign_photos'"));
@@ -83,6 +85,8 @@ class ReceivingContainerContractTest {
assertTrue(unified.contains("concat('container:',upper(trim(container_no)))"));
assertTrue(unified.contains("assert_receiving_operation_global_key_migratable"));
assertTrue(unified.contains("group_concat(column_name order by seq_in_index)= 'operator_key,idempotency_key'"));
assertTrue(unified.contains("assert_pending_orders_do_not_use_source_warehouse"));
assertTrue(unified.contains("assert_active_receiving_session_has_warehouse"));
}
@Test
@@ -105,6 +109,8 @@ class ReceivingContainerContractTest {
assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用"));
assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取"));
assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]"));
assertTrue(yaml.contains("overseasWarehouseId: { type: string"));
assertTrue(yaml.contains("overseasWarehouseName: { type: string"));
assertFalse(receivingPaths.contains("photoFileIds"));
}
@@ -31,12 +31,12 @@ class ReceivingContainerControllerTest {
@Test
void candidatesDerivesOperatorFromPlatformWarehousePickerIdentity() {
when(service.candidates("42")).thenReturn(List.of(new ReceivingContainerResponses.Candidate(
when(service.candidates("42", 900L)).thenReturn(List.of(new ReceivingContainerResponses.Candidate(
"CONT-1", "WAIT_SIGN", 2, 2, List.of("ONT8"), 4, 20, 0, false)));
controller.candidates(picker());
verify(service).candidates("42");
verify(service).candidates("42", 900L);
}
@Test
@@ -54,8 +54,16 @@ class ReceivingContainerControllerTest {
.hasMessageContaining("403");
}
@Test
void pickerAuthorityWithoutWarehouseBindingIsForbidden() {
AuthenticatedUser unbound = new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA",
Set.of(ReceivingContainerController.OPERATE_PERMISSION));
assertThatThrownBy(() -> controller.candidates(unbound)).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("403");
}
private static AuthenticatedUser picker() {
return new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA",
Set.of(ReceivingContainerController.OPERATE_PERMISSION));
Set.of(ReceivingContainerController.OPERATE_PERMISSION), false, null, null, false, 900L);
}
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class HttpUnloadPhotoFileGatewayTest {
private HttpUnloadPhotoFileGateway gateway;
private MockRestServiceServer server;
@BeforeEach
void setUp() {
RestClient.Builder builder = RestClient.builder();
server = MockRestServiceServer.bindTo(builder).build();
gateway = new HttpUnloadPhotoFileGateway();
ReflectionTestUtils.setField(gateway, "restClientBuilder", builder);
ReflectionTestUtils.setField(gateway, "baseUrl", "http://file-service");
ReflectionTestUtils.setField(gateway, "internalToken", "internal-token");
gateway.initializeClient();
}
@Test
void forgedImageBusinessFailureMapsToBadRequestInsteadOfInternalError() {
server.expect(requestTo("http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8"))
.andRespond(withSuccess("{\"code\":\"A0400\",\"message\":\"上传内容不是有效图片\",\"data\":null}",
MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.upload("100", 8L, 7L,
new MockMultipartFile("file", "forged.png", "image/png", "<script>".getBytes())))
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode().value()).isEqualTo(400));
server.verify();
}
@Test
void mismatchedPhotoMetadataMapsToBadRequest() {
server.expect(requestTo("http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8/9"))
.andRespond(withSuccess("{\"code\":\"200\",\"data\":{\"fileId\":9,\"tenantId\":101,\"businessType\":\"OVERSEAS_UNLOAD_PHOTO\",\"businessId\":\"8\"}}",
MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.validate("100", 8L, 9L))
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode().value()).isEqualTo(400));
server.verify();
}
}
@@ -88,7 +88,14 @@ class ReceivingContainerServiceBehaviorTest {
when(lines.selectList(any())).thenReturn(List.of());
when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
service.prepare("7", "receiver", "CONT-A", new ReceivingContainerRequests.Action("same-key"));
when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference());
when(plans.claimReceivingWarehouse("100", 2L, 100L)).thenReturn(1);
InboundPlan claimed = plan("CONT-A"); claimed.setOverseasWarehouseId(100L);
when(plans.selectById(2L)).thenReturn(claimed);
var response = service.prepare("7", "receiver", 100L, "CONT-A",
new ReceivingContainerRequests.Action("same-key"));
assertThat(response.overseasWarehouseId()).isEqualTo(100L);
assertThat(response.overseasWarehouseName()).isEqualTo("Shared LAX");
ArgumentCaptor<ReceivingContainerSession> selected = ArgumentCaptor.forClass(ReceivingContainerSession.class);
verify(sessions).insert(selected.capture());
assertThat(selected.getValue().getCustomerId()).isEqualTo("100");
@@ -98,7 +105,7 @@ class ReceivingContainerServiceBehaviorTest {
verify(operations).insert(saved.capture());
when(operations.find("7", "same-key")).thenReturn(saved.getValue());
assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B",
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B",
new ReceivingContainerRequests.Action("same-key"))).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("409");
}
@@ -110,20 +117,33 @@ class ReceivingContainerServiceBehaviorTest {
when(plans.selectList(any())).thenReturn(List.of(first, second), List.of(first), List.of(second));
when(lines.selectList(any())).thenReturn(List.of());
assertThat(service.candidates("7")).extracting("containerNo").containsExactly("CONT-A", "CONT-B");
assertThat(service.candidates("7", 100L)).extracting("containerNo").containsExactly("CONT-A", "CONT-B");
InboundPlan ambiguous = plan("CONT-A"); ambiguous.setId(4L); ambiguous.setCustomerId("200");
org.mockito.Mockito.reset(plans, sessions);
when(plans.selectList(any())).thenReturn(List.of(first, ambiguous));
assertThatThrownBy(() -> service.candidates("7")).isInstanceOf(ResponseStatusException.class)
assertThatThrownBy(() -> service.candidates("7", 100L)).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("exactly one customer");
}
@Test
void candidatesIncludeUnassignedAndSameWarehouseButHideOtherWarehouse() {
InboundPlan unassigned = plan("CONT-A");
InboundPlan same = plan("CONT-B"); same.setId(3L); same.setOverseasWarehouseId(100L);
InboundPlan other = plan("CONT-C"); other.setId(4L); other.setOverseasWarehouseId(200L);
when(plans.selectList(any())).thenReturn(List.of(unassigned, same, other),
List.of(unassigned), List.of(same));
when(lines.selectList(any())).thenReturn(List.of());
assertThat(service.candidates("7", 100L)).extracting("containerNo")
.containsExactly("CONT-A", "CONT-B");
}
@Test
void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
assertThatThrownBy(() -> service.detail("7", "CONT-B"))
assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
}
@@ -131,13 +151,13 @@ class ReceivingContainerServiceBehaviorTest {
void prepareEnforcesBothActiveAccountAndActiveContainerUniqueness() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B",
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B",
new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class);
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
org.mockito.Mockito.reset(sessions, operations);
when(sessions.lockActiveByContainer("CONTAINER:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN"));
assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B",
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B",
new ReceivingContainerRequests.Action("container-lock"))).isInstanceOf(ResponseStatusException.class);
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
}
@@ -356,6 +376,7 @@ class ReceivingContainerServiceBehaviorTest {
private static ReceivingContainerSession session(String containerNo, String status) {
ReceivingContainerSession value = new ReceivingContainerSession();
value.setId(8L); value.setCustomerId("100"); value.setContainerNo(containerNo); value.setOperatorKey("7");
value.setOverseasWarehouseId(100L); value.setOverseasWarehouseName("Shared LAX");
value.setStatus(status); value.setVersion(0);
return value;
}
@@ -378,6 +399,10 @@ class ReceivingContainerServiceBehaviorTest {
"AVAILABLE", fbaCode, 0);
}
private static WarehouseResourceGateway.WarehouseReference warehouseReference() {
return new WarehouseResourceGateway.WarehouseReference(100L, "SHARED-LAX", "Shared LAX", "ACTIVE");
}
private String hash(Object value) {
try {
String json = new ObjectMapper().findAndRegisterModules().copy()
@@ -4,8 +4,11 @@ import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryOperation;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import java.io.IOException;
@@ -69,6 +72,16 @@ class InventorySchemaContractTest {
}
}
@Test
void positionQuerySqlReferencesOnlyFieldsExposedByThePositionQueryDto() throws NoSuchMethodException {
Select select = InventoryQueryMapper.class.getMethod("positions", String.class, long.class,
InventoryQueryRequests.PositionQuery.class).getAnnotation(Select.class);
String sql = String.join(" ", select.value());
assertFalse(sql.contains("q.skuCode"));
assertFalse(sql.contains("q.warehouseLocationCode"));
assertFalse(sql.contains("sku_code"));
}
@Test
void incrementalMigrationIsAdditiveAndRerunnable() throws IOException {
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8)
@@ -22,19 +22,25 @@ class InventoryQueryControllerTest {
@Test
void platformBalancePageQueriesAllCustomers() {
controller.balances(user(0L, "PLATFORM"), query);
controller.balances(user(0L, "PLATFORM"), null, query);
verify(service).balances(eq(null), eq(query));
}
@Test
void platformBalancePageUsesExplicitCustomerScope() {
controller.balances(user(0L, "PLATFORM"), " 10001 ", query);
verify(service).balances(eq("10001"), eq(query));
}
@Test
void merchantCannotUsePlatformInventoryController() {
assertThrows(ResponseStatusException.class,
() -> controller.summary(user(100L, "MERCHANT"), query));
() -> controller.summary(user(100L, "MERCHANT"), null, query));
}
@Test
void platformSummaryQueriesAllCustomers() {
controller.summary(user(0L, "PLATFORM"), query);
controller.summary(user(0L, "PLATFORM"), null, query);
verify(service).dashboard(eq(null), eq(query));
}
@@ -43,7 +49,7 @@ class InventoryQueryControllerTest {
InventoryQueryRequests.PositionQuery positionQuery =
new InventoryQueryRequests.PositionQuery(null, null, null);
assertThrows(ResponseStatusException.class,
() -> controller.positions(null, 1L, positionQuery));
() -> controller.positions(null, 1L, null, positionQuery));
}
private static AuthenticatedUser user(long customerId, String appCode) {
@@ -4,7 +4,7 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com
class LoadingCompletedConsumerTest {
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; @Mock InventoryCommandService inventory; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(inboundPlans.convert(anyString(),anyLong())).thenReturn(new InboundPlanResponse(1L,"LIP-1","100",1L,"MSCU1","PENDING_ASSIGNMENT",1,1,BigDecimal.ONE,List.of(),List.of(),List.of())); }
@Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),any(InventoryCommandRequests.InTransit.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
@Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),any(InventoryCommandRequests.InTransit.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
@Test void inboundSnapshotUsesCustomerIdAndHasNoPhantomTenantColumn() throws Exception {