This commit is contained in:
2026-09-10 17:11:33 +08:00
parent 1ee32b7a6f
commit 2e6a18c58e
54 changed files with 2094 additions and 167 deletions
@@ -37,7 +37,7 @@ public class DeliveryTransferController {
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
@Operation(summary = "Create one-click dropship outbound and push a PDA picking task")
public ApiResponse<DeliveryResponses.PdaTask> createOneClick(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@Valid @RequestBody DeliveryRequests.OneClickOutbound request) {
return ApiResponse.success(service.createOneClick(requiredCustomer(user, customerId), operator(user), request));
}
@@ -80,7 +80,12 @@ public final class DeliveryRequests {
}
}
public record PdaPick(@NotNull Long outboundPalletItemId, Long inventoryPositionId,
@NotNull @Min(1) Long packages, @NotBlank @Size(max=255) String idempotencyKey) {}
@NotNull @Min(1) Long packages, @NotBlank @Size(max=255) String idempotencyKey,
@Size(max=500) List<@NotBlank @Size(max=255) String> cartonMarks) {
public PdaPick(Long outboundPalletItemId, Long inventoryPositionId, Long packages, String idempotencyKey) {
this(outboundPalletItemId, inventoryPositionId, packages, idempotencyKey, List.of());
}
}
public record PdaComplete(@NotBlank @Size(max=255) String idempotencyKey) {}
public record Cancel(@NotBlank @Size(max=1000) String reason) {}
}
@@ -50,6 +50,12 @@ import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
@@ -88,6 +94,9 @@ public class DeliveryTransferService {
@Resource private LastlegOrderMapper tailOrders;
@Resource private LastlegOrderStatusHistoryMapper histories;
@Resource private LastlegOrderDetailMapper tailOrderDetails;
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private RelabelItemMapper relabelItems;
@Resource private RelabelApplicationService relabelService;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Transactional
@@ -358,7 +367,7 @@ public class DeliveryTransferService {
if (replay != null) {
if (!task.getId().equals(replay.getTaskId())
|| !request.outboundPalletItemId().equals(replay.getOutboundPalletItemId())
|| !request.inventoryPositionId().equals(replay.getInventoryPositionId())
|| !Objects.equals(request.inventoryPositionId(), replay.getInventoryPositionId())
|| !request.packages().equals(replay.getPackages())) {
throw conflict("Idempotency key was reused with a different outbound pick request");
}
@@ -393,6 +402,62 @@ public class DeliveryTransferService {
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
return taskResult(outbound, task);
}
if ("RELABEL_OUTBOUND".equals(outbound.getOutboundType())) {
if (request.inventoryPositionId() == null) throw conflict("换标出库必须确认实际库存位置");
InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId());
if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId())
|| !warehouseId.equals(selectedPosition.getOverseasWarehouseId())
|| !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) {
throw conflict("确认的库存位置不属于该换标出库托盘");
}
List<String> expectedMarks = Arrays.stream(Optional.ofNullable(item.getCartonMarks()).orElse("").split(","))
.map(String::trim).filter(StringUtils::hasText).distinct().sorted().toList();
List<String> submittedMarks = request.cartonMarks() == null ? List.of() : request.cartonMarks();
List<String> scannedMarks = submittedMarks.stream().map(String::trim).filter(StringUtils::hasText)
.distinct().sorted().toList();
if (scannedMarks.size() != submittedMarks.size() || !expectedMarks.equals(scannedMarks)) {
throw conflict("必须扫描并提交该换标出库任务的全部指定箱唛");
}
if (request.packages().intValue() != value(item.getPackageCount()) || value(item.getPickedPackageCount()) > 0) {
throw conflict("换标出库托盘必须按指定箱唛一次完成拆托确认");
}
RelabelApplication application = relabelApplications.lockByOutbound(customerId, outbound.getOutboundNo());
if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
throw conflict("换标单尚未完成客户确认或已不可出库");
}
List<RelabelItem> sourceItems = relabelItems.selectList(new LambdaQueryWrapper<RelabelItem>()
.eq(RelabelItem::getApplicationId, application.getId())
.eq(RelabelItem::getCustomerId, customerId)
.eq(RelabelItem::getSourceInventoryPositionId, request.inventoryPositionId()));
long sourcePackages = sourceItems.stream().mapToLong(source -> value(source.getNotifiedPackageCount())).sum();
if (sourcePackages != request.packages()) throw conflict("换标出库来源库存快照与任务箱数不一致");
int releaseIndex = 0;
for (RelabelItem source : sourceItems) {
inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move(
request.inventoryPositionId(), value(source.getNotifiedPackageCount()), application.getRelabelNo(), null,
"RELABEL:" + application.getRelabelNo(), request.idempotencyKey() + ":RELEASE:" + releaseIndex++,
"换标出库拆托前释放原冻结库存"));
}
inventoryCommands.allocate(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(),
request.packages(), application.getRelabelNo(), outbound.getOutboundNo(), null,
request.idempotencyKey() + ":ALLOCATE", "换标出库指定箱唛拆托"));
inventoryCommands.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(),
request.packages(), application.getRelabelNo(), outbound.getOutboundNo(), null,
request.idempotencyKey() + ":PICK", "换标出库指定箱唛拣货"));
inventoryCommands.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(),
request.packages(), application.getRelabelNo(), outbound.getOutboundNo(), null,
request.idempotencyKey() + ":SHIP", "换标出库指定箱唛出库"));
OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId);
pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(request.inventoryPositionId());
pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
item.setPickedPackageCount(request.packages().intValue()); item.setStatus("PICKED"); outboundPallets.updateById(item);
List<OutboundPalletItem> pickedItems = outboundPallets.findByOutbound(customerId, outbound.getId());
if (!pickedItems.isEmpty() && pickedItems.stream().allMatch(value -> "PICKED".equals(value.getStatus()))) {
outbound.setStatus("PICKED"); outbound.setUpdatedAt(LocalDateTime.now()); outboundOrders.updateById(outbound);
}
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
return taskResult(outbound, task);
}
InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId());
if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId()) || !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) throw conflict("Scanned inventory position does not belong to the outbound pallet");
if (!warehouseId.equals(selectedPosition.getOverseasWarehouseId())) {
@@ -452,6 +517,13 @@ public class DeliveryTransferService {
task.setStatus("COMPLETED"); task.setCompletionIdempotencyKey(completionKey);
task.setCompletedAt(now); task.setUpdatedAt(now); pdaTasks.updateById(task);
outbound.setStatus("OUTBOUNDED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound);
if ("RELABEL_OUTBOUND".equals(outbound.getOutboundType())) {
RelabelApplication application = relabelApplications.lockByOutbound(customerId, outbound.getOutboundNo());
if (application != null && RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
relabelService.changeStatus(customerId, application.getRelabelNo(), "PDA_OUTBOUND",
new RelabelRequests.ChangeStatus("COMPLETED", "换标出库PDA作业完成"));
}
}
return taskResult(outbound, task);
}
@@ -23,10 +23,15 @@ public final class DispatchRequests {
@Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo,
@Size(max = 32) String fbaAppointmentStatus,
@Size(max = 32) String vehicleAppointmentStatus,
@Size(max = 64) String draftToken) {
@Size(max = 64) String draftToken,
@Size(max = 64) String relabelNo) {
public PreArrange(List<Long> planPalletIds, String route, String fbaAppointmentNo,
String fbaAppointmentStatus, String vehicleAppointmentStatus) {
this(planPalletIds, route, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null);
this(planPalletIds, route, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null, null);
}
public PreArrange(List<Long> planPalletIds, String route, String fbaAppointmentNo,
String fbaAppointmentStatus, String vehicleAppointmentStatus, String draftToken) {
this(planPalletIds, route, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, null);
}
}
@@ -34,5 +34,8 @@ public class DispatchItem {
private BigDecimal volumeCbm;
/** PLAN for planned pallets; RECEIVED for warehouse/entity pallets. */
private String sourceStage;
private String cartonMarks;
private String sourceBusinessType;
private String sourceBusinessNo;
private LocalDateTime createdAt;
}
@@ -143,4 +143,32 @@ public interface DispatchCandidateMapper {
"</script>")
List<DispatchPalletCandidate> lockSelected(@Param("customerId") String customerId,
@Param("planPalletIds") List<Long> planPalletIds);
@Select("<script>" +
"SELECT NULL AS planId,NULL AS planPalletId,pos.pallet_id AS warehousePalletId,a.lastleg_order_id AS orderId," +
"a.customer_id AS customerId,NULL AS customerName,NULL AS planNo,'RELABEL_APPLICATION' AS orderType,NULL AS volumeWeightKg,NULL AS containerNo," +
"NULL AS appointmentAt,NULL AS expectedLoadingAt,a.overseas_warehouse_id AS overseasWarehouseId,MIN(ri.source_warehouse_name) AS overseasWarehouseName," +
"MIN(a.relabel_no) AS orderNo,a.relabel_no AS orderRefs,MIN(ri.source_inbound_code) AS inboundCode,pos.pallet_no AS palletNo,NULL AS destinationWarehouseId," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseCode," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseName," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS fbaWarehouseCode," +
"SUM(ri.notified_package_count) AS packageCount,NULL AS volumeCbm,'RELABEL' AS sourceStage," +
"GROUP_CONCAT(DISTINCT CONCAT(ri.carton_mark,'->',ri.fba_carton_mark) ORDER BY ri.carton_mark SEPARATOR ',') AS cartonMarks " +
"FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " +
"JOIN ll_inventory_position pos ON pos.id=ri.source_inventory_position_id AND pos.customer_id=a.customer_id " +
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
"<if test='orderNo != null and orderNo != \"\"'>AND a.relabel_no LIKE CONCAT('%',#{orderNo},'%') </if>" +
"AND pos.available_packages &gt; 0 " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.customer_id=a.customer_id AND di.source_stage='RELABEL' AND di.order_refs=a.relabel_no) " +
"GROUP BY a.id,a.lastleg_order_id,a.customer_id,a.overseas_warehouse_id,a.relabel_no,pos.pallet_id,pos.pallet_no" +
"</script>")
List<DispatchPalletCandidate> relabelAvailable(@Param("customerId") String customerId,
@Param("orderNo") String orderNo,
@Param("relabelNo") String relabelNo);
@org.apache.ibatis.annotations.Update("UPDATE ll_relabel_application SET fulfillment_status='PRE_DISPATCH_CREATED',updated_at=CURRENT_TIMESTAMP(3) " +
"WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND fulfillment_status='WAIT_PRE_DISPATCH'")
int markRelabelPreDispatched(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
}
@@ -84,8 +84,15 @@ public class DispatchService {
public List<DispatchPalletCandidate> platformCandidates(String orderNo, String fbaWarehouseCode,
String sourceStage, String draftToken) {
validateSourceStage(sourceStage);
return candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
List<DispatchPalletCandidate> result = candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
trimToNull(sourceStage), trimToNull(draftToken));
if (!StringUtils.hasText(sourceStage) || "RECEIVED".equals(sourceStage)) {
List<DispatchPalletCandidate> relabel = candidates.relabelAvailable(null, trimToNull(orderNo), null);
if (relabel != null && !relabel.isEmpty()) {
result = java.util.stream.Stream.concat(result.stream(), relabel.stream()).toList();
}
}
return result;
}
public List<DispatchPalletCandidate> platformCandidates(String orderNo, String fbaWarehouseCode, String sourceStage) {
return platformCandidates(orderNo, fbaWarehouseCode, sourceStage, null);
@@ -212,6 +219,9 @@ public class DispatchService {
@Transactional
public DispatchResponse preArrange(String customerId, String operatorId, DispatchRequests.PreArrange request) {
if (StringUtils.hasText(request.relabelNo())) {
return preArrangeRelabel(customerId, operatorId, request);
}
if (!StringUtils.hasText(customerId)) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"No customer business data is available for dispatch");
@@ -355,6 +365,24 @@ public class DispatchService {
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
}
@Transactional
private DispatchResponse preArrangeRelabel(String customerId, String operatorId, DispatchRequests.PreArrange request) {
List<DispatchPalletCandidate> selected = candidates.relabelAvailable(customerId, null, request.relabelNo());
Set<Long> requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet());
selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList();
if (selected.isEmpty() || selected.size() != requested.size()) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单托盘候选已变化,请刷新后重试");
String warehouse = selected.get(0).overseasWarehouseId();
if (selected.stream().anyMatch(c -> !Objects.equals(warehouse, c.overseasWarehouseId()))) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单只能选择同一海外仓");
LocalDateTime now = LocalDateTime.now();
DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId);
dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName());
dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch);
for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setVolumeCbm(BigDecimal.ZERO); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.cartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); }
DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用");
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
}
private void assertNoDetainedOrders(String customerId, List<DispatchPalletCandidate> selected) {
List<Long> orderIds = selected.stream().map(DispatchPalletCandidate::orderId)
.filter(Objects::nonNull).distinct().sorted().toList();
@@ -42,6 +42,13 @@ public class PdaPalletOperationController {
return ApiResponse.success(service.disassemble(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/relabel-disassemble")
public ApiResponse<PalletOperationResponses.Operation> relabelDisassemble(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.RelabelDisassemble request) {
return ApiResponse.success(service.relabelDisassemble(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/swap")
public ApiResponse<PalletOperationResponses.Operation> swap(
@AuthenticationPrincipal AuthenticatedUser user,
@@ -31,6 +31,14 @@ public final class PalletOperationRequests {
@NotEmpty @Size(max = 20) List<@Valid TargetMove> targets,
@Size(max = 500) String reason) { }
/** 换标场景按箱唛拆托并绑定到一个目标托盘。箱唛位置由服务端从换标单快照解析。 */
public record RelabelDisassemble(@NotBlank @Size(max = 128) String idempotencyKey,
@NotBlank @Size(max = 64) String relabelNo,
@Valid PalletExpectation source,
@Valid PalletExpectation target,
@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks,
@Size(max = 500) String reason) { }
public record Swap(@NotBlank @Size(max = 128) String idempotencyKey,
@Valid PalletExpectation source,
@Valid PalletExpectation target,
@@ -13,6 +13,11 @@ import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
import com.baiyue.lastleg.palletops.model.PalletInventoryRow;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.service.RelabelPalletMutationService;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -52,6 +57,9 @@ public class PdaPalletOperationService {
@Resource private PalletPhysicalGateway physical;
@Resource private PdaWarehouseService history;
@Resource private ObjectMapper objectMapper;
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private RelabelItemMapper relabelItems;
@Resource private RelabelPalletMutationService relabelPalletMutations;
private TransactionTemplate transaction;
private TransactionTemplate newTransaction;
@@ -113,6 +121,64 @@ public class PdaPalletOperationService {
false));
}
public PalletOperationResponses.Operation relabelDisassemble(PdaWarehouseIdentity identity,
PalletOperationRequests.RelabelDisassemble request) {
PalletOperationResponses.Operation replay = replayIfPresent(identity, "RELABEL_PALLET_DISASSEMBLE",
request.idempotencyKey(), request);
if (replay != null) return replay;
String relabelNo = required(request.relabelNo(), "换标单号不能为空");
RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo);
if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) {
throw conflict("换标单尚未完成客户确认,不能执行拆托");
}
if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) {
throw conflict("换标单不属于 Token 冻结的海外仓");
}
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
request.source(), List.of(request.target()));
PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode()));
PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode()));
if (source.palletId() == target.palletId()) throw conflict("原托盘和目标托盘不能相同");
List<String> marks = request.cartonMarks().stream().map(String::trim).filter(value -> !value.isEmpty()).toList();
if (marks.size() != request.cartonMarks().size() || new HashSet<>(marks).size() != marks.size()) {
throw badRequest("箱唛不能为空且不能重复");
}
List<RelabelItem> selected = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>()
.eq("application_id", application.getId()).eq("customer_id", application.getCustomerId())
.in("carton_mark", marks));
if (selected.size() != marks.size()
|| selected.stream().map(RelabelItem::getCartonMark).distinct().count() != marks.size()) {
throw conflict("提交的箱唛不属于当前换标单");
}
Map<Long, Long> packagesByPosition = new LinkedHashMap<>();
Map<Long, List<RelabelItem>> itemsByPosition = new LinkedHashMap<>();
for (RelabelItem item : selected) {
if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null
|| item.getNotifiedPackageCount() <= 0) throw conflict("换标箱唛缺少库存位置或箱数快照");
InventoryPosition position = positions.selectById(item.getSourceInventoryPositionId());
if (position == null || !Objects.equals(position.getCustomerId(), application.getCustomerId())
|| !Objects.equals(position.getPalletId(), source.palletId())
|| !Objects.equals(position.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) {
throw conflict("扫描的原托盘与换标箱唛实际库存位置不一致");
}
packagesByPosition.merge(position.getId(), item.getNotifiedPackageCount().longValue(), Long::sum);
itemsByPosition.computeIfAbsent(position.getId(), ignored -> new ArrayList<>()).add(item);
}
List<PlannedMove> moves = packagesByPosition.entrySet().stream().map(entry -> {
InventoryPosition position = positions.selectById(entry.getKey());
if (value(position.getHoldPackages()) < entry.getValue()) throw conflict("换标冻结库存箱数不足");
return new PlannedMove(entry.getKey(), entry.getValue(), value(position.getVersion()), source.palletId(),
source.palletCode(), target.palletId(), target.palletCode(), target.locationId(),
locationCode(identity.overseasWarehouseId(), target.locationId()), InventoryBucket.HOLD);
}).toList();
requireCommonDestination(identity.overseasWarehouseId(), moves, List.of(target));
Plan base = plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false);
Plan relabelPlan = new Plan(base.pallets(), base.moves(), base.changes(),
new RelabelMutation(application, itemsByPosition));
return execute(identity, "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(), request,
request.reason(), relabelPlan);
}
public PalletOperationResponses.Operation swap(PdaWarehouseIdentity identity,
PalletOperationRequests.Swap request) {
PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_SWAP",
@@ -130,7 +196,7 @@ public class PdaPalletOperationService {
requireMovable(row, value(row.getAvailablePackages()));
return new PlannedMove(row.getPositionId(), value(row.getAvailablePackages()), value(row.getVersion()),
source.palletId(), source.palletCode(), target.palletId(), target.palletCode(), target.locationId(),
locationCode(identity.overseasWarehouseId(), target.locationId()));
locationCode(identity.overseasWarehouseId(), target.locationId()), InventoryBucket.AVAILABLE);
}).toList();
return execute(identity, "PALLET_SWAP", request.idempotencyKey(), request, request.reason(),
plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false));
@@ -149,7 +215,7 @@ public class PdaPalletOperationService {
requireMovable(row, value(row.getAvailablePackages()));
return new PlannedMove(row.getPositionId(), value(row.getAvailablePackages()), value(row.getVersion()),
source.palletId(), source.palletCode(), 0L, "UNBOUND", source.locationId(),
locationCode(identity.overseasWarehouseId(), source.locationId()));
locationCode(identity.overseasWarehouseId(), source.locationId()), InventoryBucket.AVAILABLE);
}).toList();
return execute(identity, "PALLET_UNBIND", request.idempotencyKey(), request, request.reason(),
plan(identity.overseasWarehouseId(), pallets, moves, Set.of(), true));
@@ -183,8 +249,10 @@ public class PdaPalletOperationService {
private PalletOperationResponses.Operation mutate(PdaWarehouseIdentity identity, PdaPalletOperation operation,
Plan plan, String key, String requestHash, String reason) {
if (plan.relabelMutation() != null) lockRelabelMutation(plan.relabelMutation(), identity.overseasWarehouseId());
Set<Long> seen = new HashSet<>();
LinkedHashSet<String> customers = new LinkedHashSet<>();
Map<Long, InventoryPosition> relabelTargets = new LinkedHashMap<>();
for (PlannedMove move : plan.moves()) {
if (!seen.add(move.positionId())) throw conflict("同一库存行不能在一次托盘作业中重复移动");
PalletInventoryRow locked = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()),
@@ -193,10 +261,14 @@ public class PdaPalletOperationService {
throw conflict("库存所在托盘已变化,请重新扫码");
}
if (value(locked.getVersion()) != move.expectedVersion()) throw conflict("库存版本已变化,请重新扫码");
requireMovable(locked, move.packages());
if (move.bucket() == InventoryBucket.HOLD) requireHeld(locked, move.packages());
else requireMovable(locked, move.packages());
customers.add(locked.getCustomerId());
moveInventory(identity, operation, locked, move, key, reason);
InventoryPosition target = moveInventory(identity, operation, locked, move, key, reason);
if (plan.relabelMutation() != null) relabelTargets.put(move.positionId(), target);
}
if (plan.relabelMutation() != null) relabelPalletMutations.moveSelectedItems(
plan.relabelMutation().application(), plan.relabelMutation().itemsByPosition(), relabelTargets);
for (PalletChange change : plan.changes()) {
long actual = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), change.pallet().palletId());
boolean shouldBeEmpty = "IDLE".equals(change.targetStatus());
@@ -219,12 +291,22 @@ public class PdaPalletOperationService {
return result;
}
private void moveInventory(PdaWarehouseIdentity identity, PdaPalletOperation operation,
PalletInventoryRow sourceRow, PlannedMove move, String key, String reason) {
private void lockRelabelMutation(RelabelMutation mutation, long warehouseId) {
RelabelApplication locked = relabelApplications.lockAnyByRelabelNo(mutation.application().getRelabelNo());
if (locked == null || !"READY_FOR_OUTBOUND".equals(locked.getStatus())
|| !Objects.equals(locked.getCustomerId(), mutation.application().getCustomerId())
|| !Objects.equals(locked.getOverseasWarehouseId(), String.valueOf(warehouseId))
|| !Objects.equals(locked.getOutboundOrderNo(), mutation.application().getOutboundOrderNo())) {
throw conflict("换标单状态或履约单据已变化,请重新扫码");
}
}
private InventoryPosition moveInventory(PdaWarehouseIdentity identity, PdaPalletOperation operation,
PalletInventoryRow sourceRow, PlannedMove move, String key, String reason) {
InventoryPosition source = positions.selectById(sourceRow.getPositionId());
if (source == null) throw conflict("库存行不存在");
long sourceBefore = value(source.getAvailablePackages());
source.setAvailablePackages(sourceBefore - move.packages());
long sourceBefore = bucketValue(source, move.bucket());
setBucketValue(source, move.bucket(), sourceBefore - move.packages());
source.setStatus(positionPackages(source) == 0 ? "EMPTY" : "OCCUPIED");
source.setVersion(value(source.getVersion()) + 1); source.setUpdatedAt(LocalDateTime.now());
positions.updateById(source);
@@ -248,8 +330,8 @@ public class PdaPalletOperationService {
if (target == null) throw duplicate;
}
}
long targetBefore = value(target.getAvailablePackages());
target.setAvailablePackages(targetBefore + move.packages()); target.setStatus("OCCUPIED");
long targetBefore = bucketValue(target, move.bucket());
setBucketValue(target, move.bucket(), targetBefore + move.packages()); target.setStatus("OCCUPIED");
target.setWarehouseLocationId(move.targetLocationId());
target.setWarehouseLocationCode(move.targetLocationCode());
target.setVersion(value(target.getVersion()) + 1); target.setUpdatedAt(LocalDateTime.now());
@@ -261,6 +343,7 @@ public class PdaPalletOperationService {
sourceBefore, -move.packages(), sourceBefore - move.packages(), move.sourcePalletCode(), move.targetPalletCode(), reason);
ledger(identity, operation, balance, target, key + ":" + source.getId() + ":IN",
targetBefore, move.packages(), targetBefore + move.packages(), move.sourcePalletCode(), move.targetPalletCode(), reason);
return target;
}
private void ledger(PdaWarehouseIdentity identity, PdaPalletOperation operation, InventoryBalance balance,
@@ -302,7 +385,7 @@ public class PdaPalletOperationService {
}
changes.sort(Comparator.comparingInt((PalletChange change) -> "IDLE".equals(change.targetStatus()) ? 0 : 1)
.thenComparingLong(change -> change.pallet().palletId()));
return new Plan(pallets, moves, changes);
return new Plan(pallets, moves, changes, null);
}
private List<PlannedMove> plannedLines(long warehouseId, List<PalletOperationRequests.MoveLine> lines,
@@ -325,7 +408,7 @@ public class PdaPalletOperationService {
requireMovable(row, line.packages());
result.add(new PlannedMove(row.getPositionId(), line.packages(), line.expectedPositionVersion(),
row.getPalletId(), row.getPalletNo(), target.palletId(), target.palletCode(), target.locationId(),
locationCode(warehouseId, target.locationId())));
locationCode(warehouseId, target.locationId()), InventoryBucket.AVAILABLE));
}
requireUniqueLines(result);
return result;
@@ -494,6 +577,21 @@ public class PdaPalletOperationService {
throw conflict("冻结、已分配、已拣或收货中的库存不能执行托盘作业");
}
}
private static void requireHeld(PalletInventoryRow row, long packages) {
if (packages <= 0 || value(row.getHoldPackages()) < packages) throw conflict("换标冻结库存箱数不足");
if (value(row.getAllocatedPackages()) > 0 || value(row.getPickedPackages()) > 0
|| value(row.getReceivingPackages()) > 0) {
throw conflict("已分配、已拣或收货中的库存不能执行换标拆托");
}
}
private static long bucketValue(InventoryPosition position, InventoryBucket bucket) {
return bucket == InventoryBucket.HOLD ? value(position.getHoldPackages())
: value(position.getAvailablePackages());
}
private static void setBucketValue(InventoryPosition position, InventoryBucket bucket, long packages) {
if (bucket == InventoryBucket.HOLD) position.setHoldPackages(packages);
else position.setAvailablePackages(packages);
}
private static void requireUniqueLines(List<PlannedMove> moves) {
Set<Long> ids = new HashSet<>();
if (moves.stream().anyMatch(move -> !ids.add(move.positionId()))) throw badRequest("库存行不能重复提交");
@@ -517,6 +615,7 @@ public class PdaPalletOperationService {
private static String first(String first, String second) { String value = trim(first); return value == null ? trim(second) : value; }
private static String limit(String value, int size) { if (value == null) return null; return value.length() <= size ? value : value.substring(0, size); }
private static long value(Long value) { return value == null ? 0L : value; }
private static int value(Integer value) { return value == null ? 0 : value; }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
@@ -524,9 +623,13 @@ public class PdaPalletOperationService {
private record PlannedMove(long positionId, long packages, long expectedVersion,
long sourcePalletId, String sourcePalletCode,
long targetPalletId, String targetPalletCode, long targetLocationId,
String targetLocationCode) { }
String targetLocationCode, InventoryBucket bucket) { }
private record PalletChange(PalletPhysicalGateway.PalletReference pallet, String targetStatus,
long targetLocationId) { }
private record Plan(Map<String, PalletPhysicalGateway.PalletReference> pallets,
List<PlannedMove> moves, List<PalletChange> changes) { }
List<PlannedMove> moves, List<PalletChange> changes,
RelabelMutation relabelMutation) { }
private record RelabelMutation(RelabelApplication application,
Map<Long, List<RelabelItem>> itemsByPosition) { }
private enum InventoryBucket { AVAILABLE, HOLD }
}
@@ -12,6 +12,11 @@ import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.ContentDisposition;
import java.nio.charset.StandardCharsets;
import java.util.List;
@RestController
@@ -34,10 +39,38 @@ public class MerchantRelabelApplicationController {
@GetMapping("/{relabelNo}")
public ApiResponse<RelabelResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo) { return ApiResponse.success(service.detail(customer(user), relabelNo)); }
@PostMapping(value = "/{relabelNo}/shipping-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<RelabelResponses.Detail> uploadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @RequestPart("file") MultipartFile file) {
return ApiResponse.success(service.uploadShippingLabel(customer(user), relabelNo, operatorLong(user), file));
}
@GetMapping("/{relabelNo}/shipping-label/{fileId}/download")
public ResponseEntity<byte[]> downloadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @PathVariable long fileId) {
var file = service.downloadShippingLabel(customer(user), relabelNo, fileId);
return download(file.content(), file.fileName(), file.contentType());
}
@GetMapping("/{relabelNo}/completion-photos/{fileId}/download")
public ResponseEntity<byte[]> downloadCompletionPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @PathVariable long fileId) {
var file = service.downloadCompletionPhoto(customer(user), relabelNo, fileId);
return download(file.content(), file.fileName(), file.contentType());
}
@PostMapping("/{relabelNo}/confirm-completion")
public ApiResponse<RelabelResponses.Detail> confirmCompletion(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody RelabelRequests.CustomerConfirm request) {
return ApiResponse.success(service.confirmCompletion(customer(user), relabelNo, operator(user), request));
}
private ResponseEntity<byte[]> download(byte[] content, String name, String type) {
return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore())
.header("Content-Disposition", ContentDisposition.inline().filename(name, StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(type)).contentLength(content.length).body(content);
}
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
private long operatorLong(AuthenticatedUser user) { return user.userId() == null ? 0L : user.userId(); }
}
@@ -85,6 +85,16 @@ public class RelabelApplicationController {
return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()).header("Content-Disposition", ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).contentLength(file.content().length).body(file.content());
}
@GetMapping("/{relabelNo}/completion-photos/{fileId}/download")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
public ResponseEntity<byte[]> downloadCompletionPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @PathVariable long fileId) {
requirePlatform(user);
var file = service.downloadCompletionPhoto(service.customerId(relabelNo), relabelNo, fileId);
return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore())
.header("Content-Disposition", ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(file.contentType())).contentLength(file.content().length).body(file.content());
}
private void requirePlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
@@ -26,8 +26,17 @@ public final class RelabelRequests {
@NotNull @DecimalMin(value = "0.01") BigDecimal chargeAmount,
@Size(max = 1000) String remark,
@NotBlank @Size(max = 255) String idempotencyKey,
@NotEmpty @Size(max = 500) List<@Valid Item> items
) {}
@NotEmpty @Size(max = 500) List<@Valid Item> items,
@Size(max = 32) String deliveryMethod,
Boolean printShippingLabel
) {
public Create(String customerOrderNo, String operationWarehouseId, LocalDateTime labelDeadline,
String contactName, String contactPhone, BigDecimal chargeAmount, String remark,
String idempotencyKey, List<Item> items) {
this(customerOrderNo, operationWarehouseId, labelDeadline, contactName, contactPhone,
chargeAmount, remark, idempotencyKey, items, "TRUCK", false);
}
}
public record Item(
@NotNull Long sourceInventoryBalanceId,
@@ -105,4 +114,7 @@ public final class RelabelRequests {
public record ChangeStatus(@NotBlank @Size(max = 32) String status,
@Size(max = 1000) String remark) {}
public record CustomerConfirm(@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
}
@@ -13,7 +13,9 @@ public final class RelabelResponses {
int totalPackageCount, int notifiedPackageCount, String skuCodes,
String totalFileId, String totalFileName, String totalFileUrl,
BigDecimal chargeAmount, String chargeCurrency, String walletStatus,
String walletErrorMessage,
String walletErrorMessage, String deliveryMethod, boolean printShippingLabel,
String shippingLabelFileId, String shippingLabelFileName, String shippingLabelFileUrl,
String fulfillmentStatus, String outboundOrderNo, LocalDateTime customerConfirmedAt,
LocalDateTime createdAt) {}
public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName,
String productImageUrl, String productNameZh, int totalPackageCount,
@@ -37,6 +39,8 @@ public final class RelabelResponses {
String status) {}
public record History(String fromStatus, String toStatus, String operatorId,
String remark, LocalDateTime createdAt) {}
public record CompletionEvidence(Long taskId, List<Long> photoFileIds, String completionRemark,
String reviewerName, String reviewRemark, LocalDateTime completedAt) {}
public record Detail(Summary application, String remark, List<Item> items, List<History> history,
List<TailOrder> tailOrders) {}
List<TailOrder> tailOrders, CompletionEvidence completionEvidence) {}
}
@@ -20,6 +20,15 @@ public class RelabelApplication {
private LocalDateTime labelDeadline;
private String contactName;
private String contactPhone;
private String deliveryMethod;
private Boolean printShippingLabel;
private String shippingLabelFileId;
private String shippingLabelFileName;
private String shippingLabelFileUrl;
private String fulfillmentStatus;
private String outboundOrderNo;
private String customerConfirmationKey;
private LocalDateTime customerConfirmedAt;
private String remark;
private String totalFileId;
private String totalFileName;
@@ -22,4 +22,7 @@ public interface RelabelApplicationMapper extends BaseMapper<RelabelApplication>
@Select("SELECT * FROM ll_relabel_application WHERE relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE")
RelabelApplication lockAnyByRelabelNo(@Param("relabelNo") String relabelNo);
@Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND outbound_order_no=#{outboundNo} LIMIT 1 FOR UPDATE")
RelabelApplication lockByOutbound(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo);
}
@@ -10,6 +10,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
@@ -89,6 +90,24 @@ public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway {
}
}
@Override
public DownloadedFile download(String customerId, long taskId, long fileId) {
validate(customerId, taskId, fileId);
ResponseEntity<byte[]> response = client.get()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content",
tenantId(customerId), BUSINESS_TYPE, taskId, fileId)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
? HttpStatus.NOT_FOUND : HttpStatus.SERVICE_UNAVAILABLE, "Relabel photo download failed");
}).toEntity(byte[].class);
String fileName = response.getHeaders().getContentDisposition().getFilename();
String contentType = response.getHeaders().getContentType() == null
? MediaType.APPLICATION_OCTET_STREAM_VALUE : response.getHeaders().getContentType().toString();
return new DownloadedFile(response.getBody() == null ? new byte[0] : response.getBody(),
fileName == null ? "relabel-photo" : fileName, contentType);
}
private static long tenantId(String customerId) {
try { return Long.parseLong(customerId); }
catch (NumberFormatException exception) {
@@ -5,6 +5,8 @@ import org.springframework.web.multipart.MultipartFile;
public interface RelabelPhotoFileGateway {
UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file);
void validate(String customerId, long taskId, long fileId);
DownloadedFile download(String customerId, long taskId, long fileId);
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { }
record DownloadedFile(byte[] content, String fileName, String contentType) { }
}
@@ -252,8 +252,8 @@ public class PdaRelabelService {
updateTask(task, request.expectedVersion());
if ("APPROVE".equals(decision)) {
applicationService.changeStatus(application.getCustomerId(), relabelNo, operatorKey,
new RelabelRequests.ChangeStatus(COMPLETED, request.remark()));
application.setStatus(COMPLETED);
new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, request.remark()));
application.setStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING);
}
insertHistory(task, "REVIEW_" + decision, before, after, operatorKey, operatorName,
Map.of("decision", decision, "remark", Objects.toString(trim(request.remark()), "")));
@@ -24,6 +24,15 @@ import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask;
import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.entity.OutboundPdaTask;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.baiyue.lastleg.wallet.WalletResultHandler;
@@ -52,7 +61,11 @@ import java.util.stream.Collectors;
@Service
public class RelabelApplicationService implements WalletResultHandler {
public static final String PENDING_PROCESSING = "PENDING_PROCESSING";
private static final Set<String> STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", "COMPLETED", "REJECTED", "CANCELLED");
public static final String CUSTOMER_CONFIRM_PENDING = "CUSTOMER_CONFIRM_PENDING";
public static final String READY_FOR_OUTBOUND = "READY_FOR_OUTBOUND";
private static final Set<String> STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", CUSTOMER_CONFIRM_PENDING,
READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED");
private static final Set<String> DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "TRUCK_PICKUP", "EXPRESS_PICKUP", "PICKUP");
@Resource private RelabelApplicationMapper applications;
@Resource private RelabelItemMapper items;
@@ -66,6 +79,11 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private InventoryBalanceMapper inventoryBalances;
@Resource private InventoryPositionMapper inventoryPositions;
@Resource private RelabelDocumentFileGateway documentFiles;
@Resource private PdaRelabelTaskMapper pdaTasks;
@Resource private RelabelPhotoFileGateway photoFiles;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private OutboundPalletItemMapper outboundItems;
@Resource private OutboundPdaTaskMapper outboundTasks;
@Transactional(readOnly = true)
public String customerId(String relabelNo) { return require(null, relabelNo).getCustomerId(); }
@@ -94,6 +112,48 @@ public class RelabelApplicationService implements WalletResultHandler {
return detail(application.getCustomerId(), relabelNo);
}
@Transactional
public RelabelResponses.Detail uploadShippingLabel(String customerId, String relabelNo, long operatorId,
MultipartFile file) {
RelabelApplication application = lockForDocument(customerId, relabelNo);
if (!Boolean.TRUE.equals(application.getPrintShippingLabel())) {
throw conflict("This relabel order does not require a shipping label");
}
requireShippingLabel(file);
var uploaded = documentFiles.upload(application.getCustomerId(), "OVERSEAS_RELABEL_SHIPPING_LABEL",
String.valueOf(application.getId()), operatorId, file);
application.setShippingLabelFileId(String.valueOf(uploaded.fileId()));
application.setShippingLabelFileName(uploaded.fileName());
application.setShippingLabelFileUrl("/api/merchant/v1/last-leg/relabel-applications/"
+ java.net.URLEncoder.encode(relabelNo, StandardCharsets.UTF_8) + "/shipping-label/"
+ uploaded.fileId() + "/download");
application.setUpdatedAt(LocalDateTime.now());
applications.updateById(application);
return detail(application.getCustomerId(), relabelNo);
}
@Transactional(readOnly = true)
public RelabelDocumentFileGateway.DownloadedFile downloadShippingLabel(String customerId, String relabelNo,
long fileId) {
RelabelApplication application = require(customerId, relabelNo);
if (!String.valueOf(fileId).equals(application.getShippingLabelFileId())) {
throw notFound("Shipping label does not belong to this relabel order");
}
return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_SHIPPING_LABEL",
String.valueOf(application.getId()), fileId);
}
@Transactional(readOnly = true)
public RelabelPhotoFileGateway.DownloadedFile downloadCompletionPhoto(String customerId, String relabelNo,
long fileId) {
RelabelApplication application = require(customerId, relabelNo);
PdaRelabelTask task = pdaTasks.find(application.getCustomerId(), application.getId());
if (task == null || !readIds(task.getCompletionPhotoFileIds()).contains(fileId)) {
throw notFound("Completion photo does not belong to this relabel order");
}
return photoFiles.download(application.getCustomerId(), task.getId(), fileId);
}
@Transactional(readOnly = true)
public RelabelDocumentFileGateway.DownloadedFile downloadFile(String customerId, String relabelNo, String scope, Long itemId, long fileId) {
RelabelApplication application = require(customerId, relabelNo);
@@ -131,6 +191,15 @@ public class RelabelApplicationService implements WalletResultHandler {
} catch (java.io.IOException exception) { throw badRequest("无法读取PDF文件"); }
}
private static void requireShippingLabel(MultipartFile file) {
if (file == null || file.isEmpty()) throw badRequest("Shipping label file is required");
if (file.getSize() > 20L * 1024 * 1024) throw badRequest("Shipping label file exceeds 20 MB");
String contentType = Objects.toString(file.getContentType(), "").toLowerCase();
if (!(contentType.equals("application/pdf") || contentType.startsWith("image/"))) {
throw badRequest("Shipping label must be a PDF or image");
}
}
private static String documentUrl(String relabelNo, long fileId, Long itemId) {
return "/api/platform/v1/last-leg/relabel-applications/" + java.net.URLEncoder.encode(relabelNo, StandardCharsets.UTF_8) + "/files/" + fileId + "/download?scope=" + (itemId == null ? "TOTAL" : "ITEM&itemId=" + itemId);
}
@@ -163,6 +232,17 @@ public class RelabelApplicationService implements WalletResultHandler {
@Transactional
public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) {
if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required");
boolean requiresOutbound = request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item)));
String deliveryMethod = trim(request.deliveryMethod());
if (requiresOutbound && !DELIVERY_METHODS.contains(deliveryMethod)) {
throw badRequest("Delivery method must be TRUCK, PICKUP or EXPRESS when carton labels are changed");
}
if (!requiresOutbound && StringUtils.hasText(deliveryMethod)) {
throw badRequest("SKU-only relabel does not create an outbound fulfillment");
}
if (Boolean.TRUE.equals(request.printShippingLabel()) && !Set.of("EXPRESS", "EXPRESS_PICKUP").contains(deliveryMethod)) {
throw badRequest("Shipping-label printing is only available for express delivery");
}
String requestHash = hash(request);
RelabelApplication replay = applications.findKey(customerId, request.idempotencyKey().trim());
if (replay != null) {
@@ -223,6 +303,9 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setLabelDeadline(request.labelDeadline());
application.setContactName(request.contactName().trim());
application.setContactPhone(request.contactPhone().trim());
application.setDeliveryMethod(deliveryMethod);
application.setPrintShippingLabel(Boolean.TRUE.equals(request.printShippingLabel()));
application.setFulfillmentStatus(requiresOutbound ? "WAIT_RELABEL" : "NOT_REQUIRED");
application.setRemark(trim(request.remark()));
application.setStatus(PENDING_PROCESSING);
application.setItemCount(request.items().size());
@@ -285,12 +368,17 @@ public class RelabelApplicationService implements WalletResultHandler {
.eq(RelabelStatusHistory::getApplicationId, application.getId())
.orderByAsc(RelabelStatusHistory::getCreatedAt));
List<LastlegOrder> tailOrders = tailOrders(application);
PdaRelabelTask pdaTask = pdaTasks == null ? null : pdaTasks.find(recordCustomerId, application.getId());
RelabelResponses.CompletionEvidence evidence = pdaTask == null ? null
: new RelabelResponses.CompletionEvidence(pdaTask.getId(), readIds(pdaTask.getCompletionPhotoFileIds()),
pdaTask.getCompletionRemark(), pdaTask.getReviewerName(), pdaTask.getReviewRemark(),
pdaTask.getReviewedAt());
return new RelabelResponses.Detail(summary(application, detailItems), application.getRemark(),
detailItems.stream().map(this::item).toList(), statusHistory.stream().map(value ->
new RelabelResponses.History(value.getFromStatus(), value.getToStatus(), value.getOperatorId(),
value.getRemark(), value.getCreatedAt())).toList(), tailOrders.stream().map(value ->
new RelabelResponses.TailOrder(value.getId(), value.getOrderNo(), value.getOverseasWarehouseId(),
Objects.requireNonNullElse(value.getPackageCount(), 0), value.getCurrentStatus())).toList());
Objects.requireNonNullElse(value.getPackageCount(), 0), value.getCurrentStatus())).toList(), evidence);
}
@Transactional
@@ -313,8 +401,11 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setStatus(next);
application.setUpdatedAt(now);
applications.updateById(application);
if ("COMPLETED".equals(next)) {
releaseInventory(customerId, operatorId, application);
if (CUSTOMER_CONFIRM_PENDING.equals(next)) {
application.setFulfillmentStatus("WAIT_CUSTOMER_CONFIRMATION");
applications.updateById(application);
} else if ("COMPLETED".equals(next)) {
if (!StringUtils.hasText(application.getOutboundOrderNo())) releaseInventory(customerId, operatorId, application);
walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
"换标完成,结算费用", 2L);
application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application);
@@ -332,6 +423,7 @@ public class RelabelApplicationService implements WalletResultHandler {
String orderBefore = order.getCurrentStatus();
String mainStatus = STATUSES.contains(orderBefore) ? "PENDING_ASSIGNMENT" : orderBefore;
String orderStatus = switch (next) {
case CUSTOMER_CONFIRM_PENDING -> mainStatus;
case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType())
? "PENDING_ASSIGNMENT" : "COMPLETED";
case "REJECTED", "CANCELLED" -> "CANCELLED";
@@ -350,6 +442,102 @@ public class RelabelApplicationService implements WalletResultHandler {
return detail(customerId, relabelNo);
}
@Transactional
public RelabelResponses.Detail confirmCompletion(String customerId, String relabelNo, String operatorId,
RelabelRequests.CustomerConfirm request) {
RelabelApplication application = applications.lockNo(customerId, relabelNo);
if (application == null) throw notFound("Relabel application not found");
String key = request.idempotencyKey().trim();
if (key.equals(application.getCustomerConfirmationKey())) return detail(customerId, relabelNo);
if (!CUSTOMER_CONFIRM_PENDING.equals(application.getStatus())) {
throw conflict("Relabel order is not awaiting customer confirmation");
}
if (Boolean.TRUE.equals(application.getPrintShippingLabel())
&& !StringUtils.hasText(application.getShippingLabelFileId())) {
throw conflict("Upload the shipping label before confirming relabel completion");
}
LocalDateTime now = LocalDateTime.now();
String before = application.getStatus();
application.setStatus(READY_FOR_OUTBOUND);
application.setCustomerConfirmationKey(key);
application.setCustomerConfirmedAt(now);
application.setFulfillmentStatus("TRUCK".equals(application.getDeliveryMethod())
? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION");
if (!"TRUCK".equals(application.getDeliveryMethod())) {
createDirectOutbound(application, operatorId, now);
application.setFulfillmentStatus("OUTBOUND_CREATED");
}
application.setUpdatedAt(now);
applications.updateById(application);
for (LastlegOrder order : tailOrders(application)) {
String orderBefore = order.getCurrentStatus();
order.setCurrentStatus("PENDING_ASSIGNMENT");
order.setSubOrderStatus("RELABEL:" + application.getFulfillmentStatus());
order.setRelabelStatus(READY_FOR_OUTBOUND);
order.setUpdatedAt(now);
orders.updateById(order);
if (!Objects.equals(orderBefore, order.getCurrentStatus())) {
insertOrderHistory(customerId, order.getId(), orderBefore, order.getCurrentStatus(),
request.remark(), now);
}
}
insertHistory(customerId, application.getId(), before, READY_FOR_OUTBOUND, operatorId,
request.remark(), now);
return detail(customerId, relabelNo);
}
private void createDirectOutbound(RelabelApplication application, String operatorId, LocalDateTime now) {
String key = "RELABEL:" + application.getId() + ":OUTBOUND";
OutboundOrder existing = outboundOrders.findByIdempotency(application.getCustomerId(), key);
if (existing != null) {
application.setOutboundOrderNo(existing.getOutboundNo());
return;
}
List<RelabelItem> relabelItems = itemList(application.getId());
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId());
outbound.setOutboundNo(orderNoGenerator.next("RELABEL_OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType("RELABEL_OUTBOUND");
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(relabelItems.stream().mapToInt(item -> intValue(item.getNotifiedPackageCount())).sum());
outbound.setPalletCount((int) relabelItems.stream().map(RelabelItem::getSourceInventoryPositionId).filter(Objects::nonNull).distinct().count());
outbound.setRecipientInfo(application.getDeliveryMethod()); outbound.setIdempotencyKey(key);
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound);
Map<Long, List<RelabelItem>> byPosition = relabelItems.stream().collect(Collectors.groupingBy(
RelabelItem::getSourceInventoryPositionId, LinkedHashMap::new, Collectors.toList()));
for (Map.Entry<Long, List<RelabelItem>> entry : byPosition.entrySet()) {
InventoryPosition position = inventoryPositions.selectById(entry.getKey());
if (position == null || !application.getCustomerId().equals(position.getCustomerId())) {
throw conflict("Relabel source inventory position no longer exists");
}
List<RelabelItem> group = entry.getValue();
OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId());
item.setCustomerId(application.getCustomerId()); item.setOutboundOrderId(outbound.getId());
item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo());
item.setPackageCount(group.stream().mapToInt(value -> intValue(value.getNotifiedPackageCount())).sum());
item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
item.setCartonMarks(group.stream().map(RelabelItem::getCartonMark).filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
item.setDestinationWarehouseCode(group.stream().map(RelabelItem::getDestinationFbaWarehouseCode)
.filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
item.setInboundCode(group.stream().map(RelabelItem::getSourceInboundCode).filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
item.setOrderRefs(writeOutboundRefs(application.getRelabelNo(), entry.getKey(), group));
item.setCreatedAt(now); outboundItems.insert(item);
}
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(application.getCustomerId());
task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId());
task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); outboundTasks.insert(task);
application.setOutboundOrderNo(outbound.getOutboundNo());
}
private String writeOutboundRefs(String relabelNo, Long positionId, List<RelabelItem> values) {
try {
return objectMapper.writeValueAsString(Map.of("sourceType", "RELABEL_ORDER", "relabelNo", relabelNo,
"sourceInventoryPositionId", positionId, "items", values.stream().map(item -> Map.of(
"itemId", item.getId(), "cartonMark", Objects.toString(item.getCartonMark(), ""),
"destination", Objects.toString(item.getDestinationFbaWarehouseCode(), ""),
"packages", intValue(item.getNotifiedPackageCount()))).toList()));
} catch (Exception exception) { throw new IllegalStateException("Cannot save relabel outbound snapshot", exception); }
}
private void insertItem(String customerId, long applicationId, SourceInventory sourceInventory,
LocalDateTime now) {
RelabelRequests.Item source = sourceInventory.request();
@@ -391,6 +579,9 @@ public class RelabelApplicationService implements WalletResultHandler {
Objects.requireNonNullElse(value.getNotifiedPackageCount(), 0), skuCodes,
value.getTotalFileId(), value.getTotalFileName(), value.getTotalFileUrl(),
value.getChargeAmount(), value.getChargeCurrency(), value.getWalletStatus(), value.getWalletErrorMessage(),
value.getDeliveryMethod(), Boolean.TRUE.equals(value.getPrintShippingLabel()),
value.getShippingLabelFileId(), value.getShippingLabelFileName(), value.getShippingLabelFileUrl(),
value.getFulfillmentStatus(), value.getOutboundOrderNo(), value.getCustomerConfirmedAt(),
value.getCreatedAt());
}
@@ -474,6 +665,7 @@ public class RelabelApplicationService implements WalletResultHandler {
private record SourceInventory(RelabelRequests.Item request, InventoryBalance balance) {}
private static long value(Long value) { return value == null ? 0L : value; }
private static int intValue(Integer value) { return value == null ? 0 : value; }
private void releaseInventory(String customerId, String operatorId, RelabelApplication application) {
for (RelabelItem item : itemList(application.getId())) {
@@ -505,9 +697,6 @@ public class RelabelApplicationService implements WalletResultHandler {
&& (item.skuItems() == null || item.skuItems().isEmpty())) {
throw badRequest("SKU label changes require at least one SKU detail");
}
if ("CARTON_LABEL".equals(type) && !StringUtils.hasText(item.newSkuCode())) {
throw badRequest("Carton label changes require the new SKU");
}
}
private static String operationType(RelabelRequests.Item item) {
return StringUtils.hasText(item.operationType()) ? item.operationType().trim().toUpperCase() : "CARTON_LABEL";
@@ -527,6 +716,12 @@ public class RelabelApplicationService implements WalletResultHandler {
List.class, RelabelResponses.SkuItem.class));
} catch (Exception exception) { throw new IllegalStateException("Cannot read relabel SKU details", exception); }
}
private List<Long> readIds(String value) {
if (!StringUtils.hasText(value)) return List.of();
try {
return objectMapper.readValue(value, objectMapper.getTypeFactory().constructCollectionType(List.class, Long.class));
} catch (Exception exception) { throw conflict("Relabel completion photo snapshot is invalid"); }
}
private RelabelApplication require(String customerId, String relabelNo) {
RelabelApplication value = StringUtils.hasText(customerId)
? applications.findNo(customerId, relabelNo)
@@ -548,7 +743,9 @@ public class RelabelApplicationService implements WalletResultHandler {
}
private static void validateTransition(String from, String to) {
boolean allowed = PENDING_PROCESSING.equals(from) && Set.of("PROCESSING", "REJECTED", "CANCELLED").contains(to)
|| "PROCESSING".equals(from) && Set.of("COMPLETED", "REJECTED", "CANCELLED").contains(to);
|| "PROCESSING".equals(from) && Set.of(CUSTOMER_CONFIRM_PENDING, "REJECTED", "CANCELLED").contains(to)
|| CUSTOMER_CONFIRM_PENDING.equals(from) && Set.of(READY_FOR_OUTBOUND, "REJECTED", "CANCELLED").contains(to)
|| READY_FOR_OUTBOUND.equals(from) && Set.of("COMPLETED", "CANCELLED").contains(to);
if (!allowed) throw conflict("Relabel status cannot change from " + from + " to " + to);
}
private String hash(Object value) {
@@ -0,0 +1,129 @@
package com.baiyue.lastleg.relabel.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** Keeps relabel ownership and its direct-outbound snapshot aligned after a physical pallet split. */
@Service
public class RelabelPalletMutationService {
@Resource private RelabelItemMapper relabelItems;
@Resource private InventoryPositionMapper positions;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private OutboundPalletItemMapper outboundItems;
@Resource private ObjectMapper objectMapper;
public void moveSelectedItems(RelabelApplication application,
Map<Long, List<RelabelItem>> itemsBySourcePosition,
Map<Long, InventoryPosition> targetBySourcePosition) {
for (Map.Entry<Long, List<RelabelItem>> entry : itemsBySourcePosition.entrySet()) {
InventoryPosition target = targetBySourcePosition.get(entry.getKey());
if (target == null) throw conflict("换标拆托目标库存位置不存在");
for (RelabelItem item : entry.getValue()) {
item.setSourceInventoryPositionId(target.getId());
relabelItems.updateById(item);
}
}
syncDirectOutbound(application, itemsBySourcePosition, targetBySourcePosition);
}
private void syncDirectOutbound(RelabelApplication application,
Map<Long, List<RelabelItem>> itemsBySourcePosition,
Map<Long, InventoryPosition> targetBySourcePosition) {
if (application.getOutboundOrderNo() == null || application.getOutboundOrderNo().isBlank()) return;
OutboundOrder outbound = outboundOrders.lockByNo(application.getCustomerId(), application.getOutboundOrderNo());
if (outbound == null || !"RELABEL_OUTBOUND".equals(outbound.getOutboundType())) {
throw conflict("换标出库单不存在或类型不正确");
}
if (!"WAIT_PICK".equals(outbound.getStatus())) throw conflict("换标出库已开始拣货,不能再拆托");
List<OutboundPalletItem> rows = new ArrayList<>(outboundItems.findByOutbound(
application.getCustomerId(), outbound.getId()));
for (Map.Entry<Long, List<RelabelItem>> entry : itemsBySourcePosition.entrySet()) {
List<RelabelItem> selected = entry.getValue();
LinkedHashSet<String> selectedMarks = selected.stream().map(RelabelItem::getCartonMark)
.filter(Objects::nonNull).map(String::trim)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
long selectedPackages = selected.stream().mapToLong(item -> value(item.getNotifiedPackageCount())).sum();
OutboundPalletItem sourceRow = rows.stream().filter(row -> value(row.getPickedPackageCount()) == 0)
.filter(row -> cartonMarks(row).containsAll(selectedMarks)).findFirst()
.orElseThrow(() -> conflict("换标出库箱唛快照与拆托任务不一致"));
LinkedHashSet<String> remaining = cartonMarks(sourceRow);
remaining.removeAll(selectedMarks);
InventoryPosition target = targetBySourcePosition.get(entry.getKey());
if (remaining.isEmpty()) {
sourceRow.setWarehousePalletId(target.getPalletId()); sourceRow.setPalletNo(target.getPalletNo());
sourceRow.setOrderRefs(outboundRefs(application.getRelabelNo(), target.getId(), selected));
outboundItems.updateById(sourceRow);
continue;
}
int remainingPackages = value(sourceRow.getPackageCount()) - Math.toIntExact(selectedPackages);
if (remainingPackages <= 0) throw conflict("换标出库剩余箱唛与箱数快照不一致");
sourceRow.setPackageCount(remainingPackages); sourceRow.setCartonMarks(String.join(",", remaining));
List<RelabelItem> remainingItems = relabelItems.selectList(new LambdaQueryWrapper<RelabelItem>()
.eq(RelabelItem::getApplicationId, application.getId())
.eq(RelabelItem::getCustomerId, application.getCustomerId())
.eq(RelabelItem::getSourceInventoryPositionId, entry.getKey()));
sourceRow.setOrderRefs(outboundRefs(application.getRelabelNo(), entry.getKey(), remainingItems));
outboundItems.updateById(sourceRow);
OutboundPalletItem targetRow = new OutboundPalletItem();
targetRow.setId(IdWorker.getId()); targetRow.setCustomerId(sourceRow.getCustomerId());
targetRow.setOutboundOrderId(sourceRow.getOutboundOrderId()); targetRow.setWarehousePalletId(target.getPalletId());
targetRow.setPalletNo(target.getPalletNo()); targetRow.setDestinationWarehouseId(sourceRow.getDestinationWarehouseId());
targetRow.setDestinationWarehouseCode(sourceRow.getDestinationWarehouseCode());
targetRow.setDestinationWarehouseName(sourceRow.getDestinationWarehouseName());
targetRow.setPackageCount(Math.toIntExact(selectedPackages)); targetRow.setPickedPackageCount(0);
targetRow.setStatus("WAIT_PICK"); targetRow.setSkuCode(sourceRow.getSkuCode());
targetRow.setCartonMarks(String.join(",", selectedMarks)); targetRow.setInboundCode(sourceRow.getInboundCode());
targetRow.setOrderRefs(outboundRefs(application.getRelabelNo(), target.getId(), selected));
targetRow.setCreatedAt(LocalDateTime.now()); outboundItems.insert(targetRow); rows.add(targetRow);
}
outbound.setPalletCount((int) rows.stream().map(OutboundPalletItem::getWarehousePalletId)
.filter(Objects::nonNull).distinct().count());
outbound.setUpdatedAt(LocalDateTime.now()); outboundOrders.updateById(outbound);
}
private static LinkedHashSet<String> cartonMarks(OutboundPalletItem item) {
LinkedHashSet<String> result = new LinkedHashSet<>();
if (item.getCartonMarks() == null) return result;
for (String value : item.getCartonMarks().split(",")) if (!value.isBlank()) result.add(value.trim());
return result;
}
private String outboundRefs(String relabelNo, Long positionId, List<RelabelItem> items) {
try {
return objectMapper.writeValueAsString(Map.of("sourceType", "RELABEL_ORDER", "relabelNo", relabelNo,
"sourceInventoryPositionId", positionId, "items", items.stream().map(item -> Map.of(
"itemId", item.getId(), "cartonMark", Objects.toString(item.getCartonMark(), ""),
"destination", Objects.toString(item.getDestinationFbaWarehouseCode(), ""),
"packages", value(item.getNotifiedPackageCount()))).toList()));
} catch (Exception exception) {
throw new IllegalStateException("无法保存换标出库托盘快照", exception);
}
}
private static int value(Integer value) { return value == null ? 0 : value; }
private static ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message);
}
}
@@ -13,6 +13,8 @@ import java.util.List;
public final class ReturnOrderRequests {
private ReturnOrderRequests() { }
public record Create(@NotBlank @Size(max = 128) String overseasWarehouseId,
@Size(max = 128) String warehouseContactName,
@Size(max = 64) String warehouseContactPhone,
@Size(max = 128) String customerReferenceNo,
boolean needRelabel,
@Size(max = 1000) String remark,
@@ -5,7 +5,8 @@ import java.util.List;
public final class ReturnOrderResponses {
private ReturnOrderResponses() { }
public record Summary(String returnNo, String lastlegOrderNo, String overseasWarehouseId, String customerReferenceNo, String status,
public record Summary(String returnNo, String lastlegOrderNo, String overseasWarehouseId,
String warehouseContactName, String warehouseContactPhone, String customerReferenceNo, String status,
int skuTypes, long expectedQuantity, long receivedQuantity, boolean needRelabel,
String relabelNo, String relabelStatus, LocalDateTime createdAt) { }
public record Item(String skuCode, String productNameZh, long expectedQuantity,
@@ -14,6 +14,8 @@ public class ReturnOrder {
private String customerId;
private String returnNo;
private String overseasWarehouseId;
private String warehouseContactName;
private String warehouseContactPhone;
private String customerReferenceNo;
private Boolean needRelabel;
private Long lastlegOrderId;
@@ -77,6 +77,8 @@ public class ReturnReceiptService {
ReturnOrder order = new ReturnOrder();
order.setId(IdWorker.getId()); order.setCustomerId(customerId);
order.setOverseasWarehouseId(text(request.overseasWarehouseId()));
order.setWarehouseContactName(text(request.warehouseContactName()));
order.setWarehouseContactPhone(text(request.warehouseContactPhone()));
order.setReturnNo(numbers.next("RETURN", order.getOverseasWarehouseId(), order.getId()));
order.setCustomerReferenceNo(text(request.customerReferenceNo())); order.setNeedRelabel(request.needRelabel()); order.setStatus(PENDING_RECEIPT);
order.setExpectedSkuTypes(skuCodes.size()); order.setExpectedQuantity(expected); order.setReceivedQuantity(0L);
@@ -293,7 +295,7 @@ public class ReturnReceiptService {
order.getStatus(), completed, value(order.getExpectedQuantity()), value(order.getReceivedQuantity()), receipt.getSignedAt());
}
private ReturnOrderResponses.Summary summary(ReturnOrder order) { return new ReturnOrderResponses.Summary(order.getReturnNo(), order.getLastlegOrderNo(),
order.getOverseasWarehouseId(), order.getCustomerReferenceNo(), order.getStatus(),
order.getOverseasWarehouseId(), order.getWarehouseContactName(), order.getWarehouseContactPhone(), order.getCustomerReferenceNo(), order.getStatus(),
order.getExpectedSkuTypes() == null ? 0 : order.getExpectedSkuTypes(), value(order.getExpectedQuantity()),
value(order.getReceivedQuantity()), Boolean.TRUE.equals(order.getNeedRelabel()), order.getRelabelNo(), order.getRelabelStatus(), order.getCreatedAt()); }
private ReturnOrderResponses.Item item(ReturnOrderItem item) { return new ReturnOrderResponses.Item(item.getSkuCode(),
@@ -17,16 +17,16 @@ public final class WarehouseTransferRequests {
private WarehouseTransferRequests() {}
public record Create(
@NotBlank @Size(max = 128) String inboundCode,
@NotEmpty @Size(max = 200) List<@NotNull Long> sourceLastlegOrderIds,
@Size(max = 128) String inboundCode,
@Size(max = 200) List<@NotNull Long> sourceLastlegOrderIds,
@NotBlank @Size(max = 128) String operatingWarehouseId,
@NotBlank @Size(max = 128) String fbaWarehouseCode,
@Size(max = 128) String fbaWarehouseCode,
@NotBlank @Size(max = 1000) String deliveryAddress,
@Positive int packageCount,
@NotNull @DecimalMin(value = "0.0001") BigDecimal volumeCbm,
@NotBlank @Size(max = 64) String deliveryMethod,
@NotNull LocalDateTime estimatedArrivalAt,
@NotBlank @Size(max = 128) String fbaShipmentNo,
@Size(max = 128) String fbaShipmentNo,
@Size(max = 255) String trackingNo,
@Size(max = 2000) String fbaBoxNos,
@Size(max = 1000) String remark,
@@ -6,6 +6,7 @@ import java.util.List;
public final class WarehouseTransferResponses {
private WarehouseTransferResponses() {}
public record Item(String skuCode, String productNameZh, int packageCount, String shippingMark) {}
public record Carton(String cartonMark, int cartonIndex, int totalCartons, String status) {}
public record SourceOrder(Long lastlegOrderId, String lastlegOrderNo, Long inboundOrderId, String inboundCode) {}
public record Allocation(Long inventoryPositionId, Long inboundOrderId, String inboundCode,
Long palletId, String palletNo, int packageCount, String status,
@@ -21,6 +22,6 @@ public final class WarehouseTransferResponses {
String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress,
int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt,
String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark,
List<SourceOrder> sourceOrders, List<Item> items, List<Allocation> allocations,
List<SourceOrder> sourceOrders, List<Item> items, List<Carton> cartons, List<Allocation> allocations,
LocalDateTime outboundAt, LocalDateTime createdAt) {}
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.transfer.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_warehouse_transfer_carton")
public class WarehouseTransferCarton {
@TableId(type = IdType.INPUT) private Long id;
private Long transferOrderId;
private Long transferItemId;
private String customerId;
private String cartonMark;
private Integer cartonIndex;
private Integer totalCartons;
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.transfer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface WarehouseTransferCartonMapper extends BaseMapper<WarehouseTransferCarton> {
@Select("SELECT * FROM ll_warehouse_transfer_carton WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY carton_index")
List<WarehouseTransferCarton> listOrder(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId);
}
@@ -5,11 +5,13 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegInboundOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
@@ -17,22 +19,19 @@ import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder;
import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferCartonMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper;
@@ -61,7 +60,6 @@ import java.util.stream.Collectors;
@Service
public class WarehouseTransferService {
public static final String ORDER_TYPE = "WAREHOUSE_TRANSFER";
public static final String RETURN_RELABEL_ORDER_TYPE = "RETURN_RELABEL";
public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String APPROVED = "APPROVED";
public static final String REJECTED = "REJECTED";
@@ -73,16 +71,16 @@ public class WarehouseTransferService {
@Resource private WarehouseTransferOrderMapper transfers;
@Resource private WarehouseTransferItemMapper transferItems;
@Resource private WarehouseTransferCartonMapper transferCartons;
@Resource private WarehouseTransferSourceOrderMapper sourceOrders;
@Resource private WarehouseTransferInventoryAllocationMapper inventoryAllocations;
@Resource private WarehouseTransferReceiptMapper transferReceipts;
@Resource private InboundSnapshotMapper inboundSnapshots;
@Resource private LastlegInboundOrderMapper inboundOrders;
@Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderDetailMapper orderDetails;
@Resource private LastlegOrderStatusHistoryMapper orderHistories;
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private RelabelItemMapper relabelItems;
@Resource private RelabelStatusHistoryMapper relabelHistories;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private InventoryCommandService inventory;
@Resource private InventoryCommandMapper inventoryCommands;
@@ -105,30 +103,47 @@ public class WarehouseTransferService {
return detail(customerId, replay.getTransferNo());
}
String inboundCode = request.inboundCode().trim();
Long inboundOrderId = inboundSnapshots.findId(customerId, inboundCode);
if (inboundOrderId == null) throw notFound("Inbound order was not found for inboundCode " + inboundCode);
List<Long> selectedIds = request.sourceLastlegOrderIds().stream().distinct().toList();
List<LastlegOrder> selectedOrders = orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, selectedIds));
if (selectedOrders.size() != selectedIds.size()) {
throw notFound("One or more selected customer orders do not exist in the current tenant");
}
LocalDateTime now = LocalDateTime.now();
long transferId = IdWorker.getId();
String transferNo = orderNoGenerator.next("TRANSFER", request.operatingWarehouseId(), transferId);
LastlegOrder order = createTailOrder(customerId, inboundOrderId, transferNo, request, requestHash, now);
String requestedInboundCode = trim(request.inboundCode());
List<Long> selectedIds = request.sourceLastlegOrderIds() == null
? List.of() : request.sourceLastlegOrderIds().stream().distinct().toList();
boolean existingInventoryTransfer = StringUtils.hasText(requestedInboundCode) || !selectedIds.isEmpty();
if (existingInventoryTransfer && (!StringUtils.hasText(requestedInboundCode) || selectedIds.isEmpty())) {
throw badRequest("Existing inventory transfer requires both inboundCode and sourceLastlegOrderIds");
}
Long inboundOrderId;
String inboundCode;
List<LastlegOrder> selectedOrders;
if (existingInventoryTransfer) {
inboundCode = requestedInboundCode;
inboundOrderId = inboundSnapshots.findId(customerId, inboundCode);
if (inboundOrderId == null) throw notFound("Inbound order was not found for inboundCode " + inboundCode);
selectedOrders = orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, selectedIds));
if (selectedOrders.size() != selectedIds.size()) {
throw notFound("One or more selected customer orders do not exist in the current tenant");
}
} else {
inboundCode = transferNo;
inboundOrderId = createInboundSnapshot(customerId, transferId, transferNo, request, requestHash, now);
selectedOrders = List.of();
}
LastlegOrder order = createTailOrder(customerId, inboundOrderId, inboundCode, transferNo,
request, requestHash, now);
WarehouseTransferOrder transfer = new WarehouseTransferOrder();
transfer.setId(transferId); transfer.setCustomerId(customerId); transfer.setTransferNo(transferNo);
transfer.setInboundOrderId(inboundOrderId); transfer.setInboundCode(inboundCode);
transfer.setLastlegOrderId(order.getId()); transfer.setLastlegOrderNo(order.getOrderNo());
transfer.setOperatingWarehouseId(request.operatingWarehouseId().trim());
transfer.setFbaWarehouseCode(request.fbaWarehouseCode().trim());
transfer.setFbaWarehouseCode(trim(request.fbaWarehouseCode()));
transfer.setDeliveryAddress(request.deliveryAddress().trim()); transfer.setPackageCount(request.packageCount());
transfer.setDeliveryMethod(request.deliveryMethod().trim()); transfer.setEstimatedArrivalAt(request.estimatedArrivalAt());
transfer.setFbaShipmentNo(request.fbaShipmentNo().trim()); transfer.setTrackingNo(trim(request.trackingNo()));
transfer.setFbaShipmentNo(trim(request.fbaShipmentNo()));
transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim()) ? trim(request.trackingNo()) : null);
transfer.setFbaBoxNos(trim(request.fbaBoxNos())); transfer.setRemark(trim(request.remark()));
transfer.setNeedRelabel(request.needRelabel()); transfer.setStatus(PENDING_REVIEW);
transfer.setIdempotencyKey(key); transfer.setRequestHash(requestHash); transfer.setCreatedBy(trim(operatorId));
@@ -149,14 +164,19 @@ public class WarehouseTransferService {
sourceOrders.insert(link);
}
int cartonIndex = 0;
for (int index = 0; index < request.items().size(); index++) {
WarehouseTransferRequests.Item source = request.items().get(index);
insertTransferItem(customerId, transferId, source, now);
long itemId = insertTransferItem(customerId, transferId, source, now);
for (int itemCarton = 0; itemCarton < source.packageCount(); itemCarton++) {
cartonIndex++;
insertTransferCarton(customerId, transferId, itemId, transferNo,
cartonIndex, request.packageCount(), now);
}
insertOrderDetail(customerId, order.getId(), transferNo, index, source, request, now);
}
insertOrderHistory(customerId, order.getId(), null, PENDING_REVIEW, transferNo,
"Warehouse transfer submitted for review", now);
if (request.needRelabel()) createRelabel(customerId, operatorId, transfer, order, request, now);
return detail(customerId, transferNo);
}
@@ -200,6 +220,9 @@ public class WarehouseTransferService {
.eq(WarehouseTransferSourceOrder::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferSourceOrder::getId))
.stream().map(value -> new WarehouseTransferResponses.SourceOrder(value.getSourceLastlegOrderId(),
value.getSourceLastlegOrderNo(), value.getInboundOrderId(), value.getInboundCode())).toList();
List<WarehouseTransferResponses.Carton> cartons = transferCartons.listOrder(customerId, transfer.getId())
.stream().map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(),
value.getCartonIndex(), value.getTotalCartons(), value.getStatus())).toList();
Map<Long, WarehouseTransferReceipt> receiptByAllocation = transferReceipts.selectList(
new LambdaQueryWrapper<WarehouseTransferReceipt>().eq(WarehouseTransferReceipt::getCustomerId, customerId)
.eq(WarehouseTransferReceipt::getTransferOrderId, transfer.getId()))
@@ -230,7 +253,7 @@ public class WarehouseTransferService {
relabelStatus,
transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(),
transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(), transfer.getFbaShipmentNo(),
transfer.getTrackingNo(), transfer.getFbaBoxNos(), transfer.getRemark(), sources, items, allocations,
transfer.getTrackingNo(), transfer.getFbaBoxNos(), transfer.getRemark(), sources, items, cartons, allocations,
transfer.getOutboundAt(), transfer.getCreatedAt());
}
@@ -254,9 +277,15 @@ public class WarehouseTransferService {
LocalDateTime now = LocalDateTime.now();
String before = order.getCurrentStatus();
transfer.setStatus(next); transfer.setUpdatedAt(now); transfers.updateById(transfer);
String orderStatus = APPROVED.equals(next) ? PENDING_ASSIGNMENT : "CANCELLED";
boolean incomingTransit = transfer.getTransferNo().equals(transfer.getInboundCode());
String orderStatus = APPROVED.equals(next)
? (incomingTransit
? ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod()) ? "WAIT_PICKUP" : "WAIT_DELIVERY")
: PENDING_ASSIGNMENT)
: "CANCELLED";
order.setCurrentStatus(orderStatus);
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + next);
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + next + ";RELABEL:"
+ (StringUtils.hasText(order.getRelabelStatus()) ? order.getRelabelStatus() : "NOT_REQUIRED"));
order.setUpdatedAt(now); orders.updateById(order);
if (!java.util.Objects.equals(before, orderStatus)) {
insertOrderHistory(customerId, order.getId(), before, orderStatus, transferNo,
@@ -461,20 +490,41 @@ public class WarehouseTransferService {
return detail(customerId, transferNo);
}
private LastlegOrder createTailOrder(String customerId, Long inboundOrderId, String transferNo,
private Long createInboundSnapshot(String customerId, long inboundOrderId, String transferNo,
WarehouseTransferRequests.Create request, String requestHash,
LocalDateTime now) {
LastlegInboundOrder inbound = new LastlegInboundOrder();
String sourceId = "WAREHOUSE_TRANSFER:" + transferNo;
inbound.setId(inboundOrderId); inbound.setCustomerId(customerId); inbound.setSourceRegion("CUSTOMER");
inbound.setSourceInboundId(sourceId); inbound.setRootInboundId(sourceId);
inbound.setSourceOrderId(transferNo); inbound.setSourceOrderNo(transferNo); inbound.setRootOrderId(transferNo);
inbound.setInboundCode(transferNo); inbound.setDestinationWarehouseId(request.operatingWarehouseId().trim());
inbound.setDestinationType("OVERSEAS"); inbound.setPackageCount(request.packageCount());
inbound.setPalletCount(0); inbound.setVolumeCbm(request.volumeCbm());
inbound.setSourceEventId(sourceId); inbound.setSourceLoadingPlanId(ORDER_TYPE);
inbound.setSourceLoadingPlanVersion(1L); inbound.setHandoverVersion(1L);
inbound.setPayloadHash("sha256:" + requestHash); inbound.setStatus(PENDING_REVIEW);
inbound.setExceptionFlag("NONE"); inbound.setCreatedAt(now); inbound.setUpdatedAt(now);
inboundOrders.insert(inbound);
return inbound.getId();
}
private LastlegOrder createTailOrder(String customerId, Long inboundOrderId, String inboundCode, String transferNo,
WarehouseTransferRequests.Create request, String requestHash, LocalDateTime now) {
LastlegOrder order = new LastlegOrder();
order.setId(IdWorker.getId()); order.setOrderNo(orderNoGenerator.next("TRANSFER", request.operatingWarehouseId(), order.getId())); order.setCustomerId(customerId);
order.setOrderType(request.needRelabel() ? RETURN_RELABEL_ORDER_TYPE : ORDER_TYPE);
order.setInboundCode(request.inboundCode().trim()); order.setSourceRegion("CUSTOMER");
order.setOrderType(ORDER_TYPE);
order.setInboundCode(inboundCode); order.setSourceRegion("CUSTOMER");
order.setSourceInboundId("WAREHOUSE_TRANSFER:" + transferNo); order.setRootInboundId(String.valueOf(inboundOrderId));
order.setSourceOrderNo(transferNo); order.setOverseasWarehouseId(request.operatingWarehouseId().trim());
order.setFbaWarehouseCode(request.fbaWarehouseCode().trim()); order.setPackageCount(request.packageCount());
order.setFbaWarehouseCode(trim(request.fbaWarehouseCode())); order.setPackageCount(request.packageCount());
order.setVolumeCbm(request.volumeCbm());
order.setContainerNo(transferNo);
order.setPalletCount(0); order.setPickedPackageCount(0); order.setShippedPackageCount(0);
order.setEstimatedWarehouseAt(request.estimatedArrivalAt());
order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW);
order.setCurrentStatus(PENDING_REVIEW);
order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED");
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW + ";RELABEL:" + order.getRelabelStatus());
order.setCustomerRemark(trim(request.remark()));
order.setSourceEventId("WAREHOUSE_TRANSFER:" + transferNo); order.setSourceLoadingPlanId(ORDER_TYPE);
order.setSourcePayloadHash("sha256:" + requestHash); order.setCreatedAt(now); order.setUpdatedAt(now);
@@ -482,13 +532,24 @@ public class WarehouseTransferService {
return order;
}
private void insertTransferItem(String customerId, long transferId, WarehouseTransferRequests.Item source,
private long insertTransferItem(String customerId, long transferId, WarehouseTransferRequests.Item source,
LocalDateTime now) {
WarehouseTransferItem item = new WarehouseTransferItem();
item.setId(IdWorker.getId()); item.setTransferOrderId(transferId); item.setCustomerId(customerId);
item.setSkuCode(source.skuCode().trim()); item.setProductNameZh(source.productNameZh().trim());
item.setPackageCount(source.packageCount()); item.setShippingMark(trim(source.shippingMark())); item.setCreatedAt(now);
transferItems.insert(item);
return item.getId();
}
private void insertTransferCarton(String customerId, long transferId, long transferItemId,
String transferNo, int cartonIndex, int totalCartons,
LocalDateTime now) {
WarehouseTransferCarton carton = new WarehouseTransferCarton();
carton.setId(IdWorker.getId()); carton.setTransferOrderId(transferId); carton.setTransferItemId(transferItemId);
carton.setCustomerId(customerId); carton.setCartonMark(transferNo + "#" + totalCartons + "-" + cartonIndex);
carton.setCartonIndex(cartonIndex); carton.setTotalCartons(totalCartons); carton.setStatus("EXPECTED");
carton.setCreatedAt(now); carton.setUpdatedAt(now); transferCartons.insert(carton);
}
private void insertOrderDetail(String customerId, long orderId, String transferNo, int index, WarehouseTransferRequests.Item source,
@@ -497,48 +558,13 @@ public class WarehouseTransferService {
detail.setCustomerId(customerId);
detail.setOrderId(orderId); detail.setSourceInboundDetailId(transferNo + ":" + index);
detail.setSkuCode(source.skuCode().trim()); detail.setProductNameZh(source.productNameZh().trim());
detail.setPackageCount(source.packageCount()); detail.setDestinationWarehouseCode(request.fbaWarehouseCode().trim());
detail.setFbaTrackingNo(trim(request.trackingNo())); detail.setShipmentNo(request.fbaShipmentNo().trim());
detail.setPackageCount(source.packageCount()); detail.setDestinationWarehouseCode(trim(request.fbaWarehouseCode()));
detail.setFbaTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim())
? trim(request.trackingNo()) : null);
detail.setShipmentNo(trim(request.fbaShipmentNo()));
detail.setShippingMark(trim(source.shippingMark())); orderDetails.insert(detail);
}
private void createRelabel(String customerId, String operatorId, WarehouseTransferOrder transfer, LastlegOrder order,
WarehouseTransferRequests.Create request, LocalDateTime now) {
long applicationId = IdWorker.getId();
String relabelNo = orderNoGenerator.next("RELABEL", request.operatingWarehouseId(), applicationId);
RelabelApplication application = new RelabelApplication();
application.setId(applicationId); application.setCustomerId(customerId); application.setRelabelNo(relabelNo);
application.setLastlegOrderId(order.getId()); application.setCustomerOrderNo(transfer.getTransferNo());
application.setOverseasWarehouseId(request.operatingWarehouseId().trim()); application.setLabelDeadline(request.estimatedArrivalAt());
application.setContactName(StringUtils.hasText(operatorId) ? operatorId.trim() : "SYSTEM");
application.setContactPhone("N/A"); application.setRemark(trim(request.remark()));
application.setStatus(RelabelApplicationService.PENDING_PROCESSING); application.setItemCount(request.items().size());
application.setTotalPackageCount(request.packageCount()); application.setNotifiedPackageCount(request.packageCount());
application.setIdempotencyKey("WAREHOUSE_TRANSFER:" + request.idempotencyKey().trim());
application.setRequestHash(hash(request)); application.setCreatedBy(trim(operatorId));
application.setCreatedAt(now); application.setUpdatedAt(now); relabelApplications.insert(application);
for (WarehouseTransferRequests.Item source : request.items()) {
RelabelItem item = new RelabelItem();
item.setId(IdWorker.getId()); item.setApplicationId(applicationId); item.setCustomerId(customerId);
item.setSkuCode(source.skuCode().trim()); item.setProductNameZh(source.productNameZh().trim());
item.setTotalPackageCount(source.packageCount()); item.setNotifiedPackageCount(source.packageCount());
item.setShipmentNo(request.fbaShipmentNo().trim()); item.setTrackingCode(trim(request.trackingNo()));
item.setNewSkuCode(source.skuCode().trim()); item.setLabelRequirement("WAREHOUSE_TRANSFER_RELABEL");
item.setCreatedAt(now); relabelItems.insert(item);
}
RelabelStatusHistory history = new RelabelStatusHistory();
history.setId(IdWorker.getId()); history.setCustomerId(customerId); history.setApplicationId(applicationId);
history.setToStatus(RelabelApplicationService.PENDING_PROCESSING); history.setOperatorId(trim(operatorId));
history.setRemark("Created from warehouse transfer " + transfer.getTransferNo()); history.setCreatedAt(now);
relabelHistories.insert(history);
transfer.setRelabelApplicationId(applicationId); transfer.setRelabelNo(relabelNo); transfer.setUpdatedAt(now);
transfers.updateById(transfer);
order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(RelabelApplicationService.PENDING_PROCESSING);
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW + ";RELABEL:" + RelabelApplicationService.PENDING_PROCESSING);
order.setUpdatedAt(now); orders.updateById(order);
}
private void insertOrderHistory(String customerId, long orderId, String fromStatus, String toStatus,
String transferNo, String remark, LocalDateTime now) {
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory();