Merge remote-tracking branch 'origin/test' into test

This commit is contained in:
2026-09-14 17:29:27 +08:00
26 changed files with 515 additions and 23 deletions
@@ -4997,3 +4997,53 @@ ALTER TABLE wh_pallet_operation
MODIFY COLUMN after_location_id BIGINT NULL COMMENT '变更后库位ID,无库位托盘为空';
USE baiyue_lastleg;
SELECT 'PASS' AS migration_status, '无库位托盘状态流转结构已就绪' AS message;
-- 2026-09-14 贴标申请区分箱唛总文件、SKU总文件;明细文件沿用ll_relabel_item.relabel_file_*。
DROP PROCEDURE IF EXISTS add_relabel_total_document_columns;
DELIMITER $$
CREATE PROCEDURE add_relabel_total_document_columns()
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_application' AND column_name='carton_total_file_id') THEN
ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_id VARCHAR(128) NULL COMMENT '箱唛总文件ID' AFTER total_file_url;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_application' AND column_name='carton_total_file_name') THEN
ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_name VARCHAR(255) NULL COMMENT '箱唛总文件名' AFTER carton_total_file_id;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_application' AND column_name='carton_total_file_url') THEN
ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_url VARCHAR(1000) NULL COMMENT '箱唛总文件地址' AFTER carton_total_file_name;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_application' AND column_name='sku_total_file_id') THEN
ALTER TABLE ll_relabel_application ADD COLUMN sku_total_file_id VARCHAR(128) NULL COMMENT 'SKU总文件ID' AFTER carton_total_file_url;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_application' AND column_name='sku_total_file_name') THEN
ALTER TABLE ll_relabel_application ADD COLUMN sku_total_file_name VARCHAR(255) NULL COMMENT 'SKU总文件名' AFTER sku_total_file_id;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_application' AND column_name='sku_total_file_url') THEN
ALTER TABLE ll_relabel_application ADD COLUMN sku_total_file_url VARCHAR(1000) NULL COMMENT 'SKU总文件地址' AFTER sku_total_file_name;
END IF;
END$$
DELIMITER ;
CALL add_relabel_total_document_columns();
DROP PROCEDURE IF EXISTS add_relabel_total_document_columns;
DROP PROCEDURE IF EXISTS assert_relabel_document_columns_ready;
DELIMITER $$
CREATE PROCEDURE assert_relabel_document_columns_ready()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_application'
AND column_name IN ('carton_total_file_id','carton_total_file_name','carton_total_file_url',
'sku_total_file_id','sku_total_file_name','sku_total_file_url')) <> 6 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='贴标申请箱唛/SKU总文件字段未完整部署';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_item'
AND column_name IN ('relabel_file_id','relabel_file_name','relabel_file_url')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='贴标申请明细文件字段未完整部署';
END IF;
END$$
DELIMITER ;
CALL assert_relabel_document_columns_ready();
DROP PROCEDURE IF EXISTS assert_relabel_document_columns_ready;
SELECT 'PASS' AS migration_status, '贴标申请箱唛总文件、SKU总文件及明细文件字段已就绪' AS message;
@@ -60,6 +60,20 @@ public class CargoDiscardController {
return ApiResponse.success(service.review(scope(user, customerId), discardNo, operator(user), request));
}
@PostMapping("/{discardNo}/cancel")
public ApiResponse<CargoDiscardResponses.Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String discardNo) {
requireApprovalPermission(user);
return ApiResponse.success(service.cancel(scope(user, customerId), discardNo, operator(user), true));
}
@PostMapping("/{discardNo}/complete")
public ApiResponse<CargoDiscardResponses.Detail> complete(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String discardNo) {
requireApprovalPermission(user);
return ApiResponse.success(service.complete(scope(user, customerId), discardNo, operator(user)));
}
private String scope(AuthenticatedUser user, String requested) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
@@ -30,6 +30,9 @@ public class MerchantCargoDiscardController {
@GetMapping("/{discardNo}")
public ApiResponse<CargoDiscardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String discardNo) { return ApiResponse.success(service.detail(customer(user), discardNo)); }
@PostMapping("/{discardNo}/cancel")
public ApiResponse<CargoDiscardResponses.Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String discardNo) { return ApiResponse.success(service.cancel(customer(user), discardNo, operator(user), false)); }
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");
@@ -21,5 +21,9 @@ public class CargoDiscardItem {
private String productName;
private Integer availablePackagesSnapshot;
private Integer discardPackages;
private Integer actualDiscardPackages;
private String pdaStatus;
private String pdaOperatorId;
private LocalDateTime pdaCompletedAt;
private LocalDateTime createdAt;
}
@@ -16,4 +16,7 @@ public interface CargoDiscardApplicationMapper extends BaseMapper<CargoDiscardAp
@Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND discard_no=#{discardNo} LIMIT 1 FOR UPDATE")
CargoDiscardApplication lockNo(@Param("customerId") String customerId, @Param("discardNo") String discardNo);
@Select("SELECT * FROM ll_cargo_discard_application WHERE discard_no=#{discardNo} AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE")
CargoDiscardApplication findNoByWarehouse(@Param("discardNo") String discardNo, @Param("warehouseId") String warehouseId);
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.discard.pda.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.discard.pda.dto.PdaCargoDiscardDtos;
import com.baiyue.lastleg.discard.pda.service.PdaCargoDiscardService;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/pda/v1/last-leg/cargo-discards")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public class PdaCargoDiscardController {
@Resource private PdaCargoDiscardService service;
@GetMapping public ApiResponse<List<PdaCargoDiscardDtos.PalletTask>> tasks(@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(service.tasks(PdaWarehouseIdentity.require(user)));
}
@PostMapping("/resolve") public ApiResponse<PdaCargoDiscardDtos.PalletTask> resolve(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaCargoDiscardDtos.Scan request) {
return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request.palletNo()));
}
@PostMapping("/{discardNo}/pallets/{palletNo}/confirm")
public ApiResponse<PdaCargoDiscardDtos.PalletTask> confirm(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String discardNo, @PathVariable String palletNo, @Valid @RequestBody PdaCargoDiscardDtos.Confirm request) {
return ApiResponse.success(service.confirm(PdaWarehouseIdentity.require(user), discardNo, palletNo, request));
}
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.discard.pda.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.util.List;
public final class PdaCargoDiscardDtos {
private PdaCargoDiscardDtos() { }
public record Scan(@NotBlank @Size(max = 128) String palletNo) { }
public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages) { }
public record Confirm(@NotEmpty List<@Valid ActualLine> lines) { }
public record Sku(Long itemId, String skuCode, String productName, int plannedPackages,
Integer actualPackages, String status) { }
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
String palletNo, Long profileId, String status, List<Sku> skus) { }
}
@@ -0,0 +1,99 @@
package com.baiyue.lastleg.discard.pda.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.discard.dto.CargoDiscardResponses;
import com.baiyue.lastleg.discard.entity.*;
import com.baiyue.lastleg.discard.mapper.*;
import com.baiyue.lastleg.discard.pda.dto.PdaCargoDiscardDtos;
import com.baiyue.lastleg.discard.service.CargoDiscardInventoryAdjustmentService;
import com.baiyue.lastleg.discard.service.CargoDiscardService;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class PdaCargoDiscardService {
@Resource private CargoDiscardApplicationMapper applications;
@Resource private CargoDiscardItemMapper items;
@Resource private CargoDiscardCartonMapper cartons;
@Resource private DropshipOrderProfileMapper profiles;
@Resource private CargoDiscardInventoryAdjustmentService adjustments;
@Resource private CargoDiscardService discardService;
public List<PdaCargoDiscardDtos.PalletTask> tasks(PdaWarehouseIdentity identity) {
return applications.selectList(new LambdaQueryWrapper<CargoDiscardApplication>()
.eq(CargoDiscardApplication::getOverseasWarehouseId, String.valueOf(identity.overseasWarehouseId()))
.eq(CargoDiscardApplication::getStatus, CargoDiscardService.APPROVED)
.orderByAsc(CargoDiscardApplication::getCreatedAt))
.stream().flatMap(a -> palletTasks(a).stream()).toList();
}
public PdaCargoDiscardDtos.PalletTask resolve(PdaWarehouseIdentity identity, String palletNo) {
return tasks(identity).stream().filter(t -> t.palletNo().equals(palletNo.trim())).findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "该仓库没有此托盘的待废弃任务"));
}
@Transactional
public PdaCargoDiscardDtos.PalletTask confirm(PdaWarehouseIdentity identity, String discardNo,
String palletNo, PdaCargoDiscardDtos.Confirm request) {
CargoDiscardApplication application = applications.findNoByWarehouse(discardNo,
String.valueOf(identity.overseasWarehouseId()));
if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃任务不存在或不属于当前仓库");
if (!CargoDiscardService.APPROVED.equals(application.getStatus())) throw conflict("废弃任务当前不可进行 PDA 作业");
PdaCargoDiscardDtos.PalletTask task = palletTasks(application).stream().filter(t -> t.palletNo().equals(palletNo.trim()))
.findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单"));
Map<Long,Integer> actuals = request.lines().stream().collect(Collectors.toMap(PdaCargoDiscardDtos.ActualLine::itemId,
PdaCargoDiscardDtos.ActualLine::actualPackages, (a,b) -> { throw conflict("PDA 明细重复"); }));
if (!actuals.keySet().equals(task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).collect(Collectors.toSet()))) {
throw conflict("必须提交该托盘全部 SKU 的实际废弃数量");
}
LocalDateTime now = LocalDateTime.now();
for (PdaCargoDiscardDtos.Sku line : task.skus()) {
CargoDiscardItem item = items.selectById(line.itemId());
if ("COMPLETED".equals(item.getPdaStatus())) {
if (!Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId()))) throw conflict("已完成托盘的实际数量不能修改");
continue;
}
List<CargoDiscardCarton> links = cartons.listApplication(application.getCustomerId(), application.getId()).stream()
.filter(c -> c.getItemId().equals(item.getId())).toList();
adjustments.discardPalletItem(application.getCustomerId(), application, item, links,
actuals.get(item.getId()), identity.operatorKey(), now);
item.setActualDiscardPackages(actuals.get(item.getId())); item.setPdaStatus("COMPLETED");
item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); items.updateById(item);
}
if (items.listApplication(application.getCustomerId(), application.getId()).stream()
.allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) {
int actualTotal = items.listApplication(application.getCustomerId(), application.getId()).stream()
.mapToInt(i -> i.getActualDiscardPackages() == null ? 0 : i.getActualDiscardPackages()).sum();
application.setTotalPackages(actualTotal);
application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal)));
discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED,
CargoDiscardService.PDA_COMPLETED, identity.operatorKey(), "全部托盘 PDA 废弃作业完成,待仓库确认");
}
return task;
}
private List<PdaCargoDiscardDtos.PalletTask> palletTasks(CargoDiscardApplication application) {
return items.listApplication(application.getCustomerId(), application.getId()).stream()
.collect(Collectors.groupingBy(CargoDiscardItem::getProfileId, LinkedHashMap::new, Collectors.toList()))
.entrySet().stream().map(entry -> {
DropshipOrderProfile profile = profiles.selectById(entry.getKey());
if (profile == null || !application.getCustomerId().equals(profile.getCustomerId())) throw conflict("废弃明细关联托盘不存在");
List<PdaCargoDiscardDtos.Sku> skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(
i.getId(), i.getSkuCode(), i.getProductName(), i.getDiscardPackages(), i.getActualDiscardPackages(),
i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus())).toList();
String status = skus.stream().allMatch(s -> "COMPLETED".equals(s.status())) ? "COMPLETED" : "PENDING";
return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(),
application.getOverseasWarehouseId(), profile.getPalletNo(), profile.getId(), status, skus);
}).toList();
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -9,6 +9,9 @@ import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@@ -25,6 +28,42 @@ import java.util.Map;
public class CargoDiscardInventoryAdjustmentService {
@Resource private CargoDiscardInventoryMapper inventory;
@Resource private DropshipCartonLedgerMapper cartonLedgers;
@Resource private DropshipOrderProfileMapper profiles;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@Transactional(propagation = Propagation.MANDATORY)
public AdjustmentResult discardPalletItem(String customerId, CargoDiscardApplication application,
CargoDiscardItem item, List<CargoDiscardCarton> links,
int actualPackages, String operatorId, LocalDateTime occurredAt) {
if (actualPackages < 0 || actualPackages > links.size()) throw conflict("实际废弃数量超出该托盘申请数量");
DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId());
if (sku == null) throw conflict("托盘 SKU 库存不存在: " + item.getSkuCode());
long before = value(sku.getPackageCount());
if (before < actualPackages || inventory.deductSkuInventory(customerId, sku.getId(), actualPackages) != 1) {
throw conflict("托盘 SKU 库存不足: " + item.getSkuCode());
}
var profile = profiles.selectById(item.getProfileId());
var position = profile == null || profile.getInventoryPositionId() == null ? null
: positions.findTenantPosition(customerId, profile.getInventoryPositionId());
if (profile == null || !customerId.equals(profile.getCustomerId()) || position == null
|| positions.deductAvailable(customerId, profile.getInventoryPositionId(), actualPackages) != 1
|| balances.deductAvailable(customerId, position.getBalanceId(), actualPackages) != 1) {
throw conflict("托盘库存明细数量不足或托盘位置不存在");
}
for (int index = 0; index < links.size(); index++) {
CargoDiscardCarton link = links.get(index);
String target = index < actualPackages ? "DISCARDED" : "AVAILABLE";
DropshipCarton carton = inventory.lockCarton(customerId, link.getCartonId());
if (carton == null || inventory.changeCartonStatus(customerId, carton.getId(), "DISCARD_PENDING", target) != 1) {
throw conflict("托盘预留箱状态已变化");
}
if (index < actualPackages) cartonLedgers.insert(discardFact(customerId, application, item, carton, operatorId, occurredAt));
}
long after = value(inventory.skuPackages(customerId, sku.getId()));
if (after != before - actualPackages) throw conflict("托盘 SKU 库存扣减不守恒");
return new AdjustmentResult(before, actualPackages, after);
}
@Transactional(propagation = Propagation.MANDATORY)
public AdjustmentResult discard(String customerId, CargoDiscardApplication application,
@@ -19,7 +19,9 @@ import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
@@ -49,7 +51,10 @@ public class CargoDiscardService {
public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String APPROVED = "APPROVED";
public static final String REJECTED = "REJECTED";
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED);
public static final String CANCELLED = "CANCELLED";
public static final String PDA_COMPLETED = "PDA_COMPLETED";
public static final String COMPLETED = "COMPLETED";
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED, CANCELLED, PDA_COMPLETED, COMPLETED);
@Resource private CargoDiscardApplicationMapper applications;
@Resource private CargoDiscardItemMapper items;
@@ -58,6 +63,7 @@ public class CargoDiscardService {
@Resource private CargoDiscardInventoryMapper inventory;
@Resource private CargoDiscardInventoryAdjustmentService adjustments;
@Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderStatusHistoryMapper orderHistories;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}")
private BigDecimal feePerPackage = new BigDecimal("5.00");
@@ -118,6 +124,37 @@ public class CargoDiscardService {
application.setCreatedAt(now); application.setUpdatedAt(now);
applications.insert(application);
// A discard request is also a customer-visible tail-leg order. Keep the
// application as the workflow record and link the order through its
// immutable source id so cancellation/status changes stay traceable.
LastlegOrder tailOrder = new LastlegOrder();
tailOrder.setId(IdWorker.getId());
tailOrder.setOrderNo(application.getDiscardNo());
tailOrder.setCustomerId(customerId);
tailOrder.setOrderType("CARGO_DISCARD");
tailOrder.setInboundCode(application.getInboundCodes());
tailOrder.setInboundOrderNo(application.getDiscardNo());
tailOrder.setSourceRegion("LASTLEG_DISCARD");
tailOrder.setSourceInboundId("CARGO_DISCARD:" + application.getId());
tailOrder.setSourceOrderId(String.valueOf(application.getId()));
tailOrder.setSourceOrderNo(application.getDiscardNo());
tailOrder.setOverseasWarehouseId(warehouseId);
tailOrder.setOverseasWarehouseName(warehouseName);
tailOrder.setPackageCount(total);
tailOrder.setCurrentStatus(PENDING_REVIEW);
tailOrder.setVersion(0);
tailOrder.setCreatedAt(now);
tailOrder.setUpdatedAt(now);
tailOrder.setSourceEventId("CARGO_DISCARD:" + application.getId());
tailOrder.setSourceLoadingPlanId("CARGO_DISCARD");
tailOrder.setSourcePayloadHash(requestHash);
orders.insert(tailOrder);
LastlegOrderStatusHistory initialOrderHistory = new LastlegOrderStatusHistory();
initialOrderHistory.setId(IdWorker.getId()); initialOrderHistory.setCustomerId(customerId); initialOrderHistory.setOrderId(tailOrder.getId());
initialOrderHistory.setFromStatus(null); initialOrderHistory.setToStatus(PENDING_REVIEW);
initialOrderHistory.setEventId("CARGO_DISCARD_CREATE:" + application.getId()); initialOrderHistory.setRemark(trim(request.remark())); initialOrderHistory.setCreatedAt(now);
orderHistories.insert(initialOrderHistory);
for (CargoDiscardRequests.Item input : request.items()) {
reserve(customerId, application, orderCache.get(input.orderId()), input, now);
}
@@ -175,11 +212,7 @@ public class CargoDiscardService {
Map<Long, List<CargoDiscardCarton>> linksByItem = links.stream()
.collect(java.util.stream.Collectors.groupingBy(CargoDiscardCarton::getItemId));
String historyRemark = request.remark();
if (APPROVED.equals(next)) {
CargoDiscardInventoryAdjustmentService.AdjustmentResult result = adjustments.discard(
customerId, application, applicationItems, linksByItem, operatorId, now());
historyRemark = result.auditRemark(request.remark());
} else {
if (REJECTED.equals(next)) {
release(customerId, links);
}
@@ -187,10 +220,74 @@ public class CargoDiscardService {
application.setStatus(next); application.setReviewedBy(operatorId);
application.setReviewRemark(trim(request.remark())); application.setReviewedAt(reviewedAt);
application.setUpdatedAt(reviewedAt); applications.updateById(application);
LastlegOrder linked = orders.findSource(customerId, "LASTLEG_DISCARD", "CARGO_DISCARD:" + application.getId());
if (linked != null) {
linked.setCurrentStatus(next); linked.setVersion((linked.getVersion() == null ? 0 : linked.getVersion()) + 1); linked.setUpdatedAt(reviewedAt);
orders.updateById(linked);
LastlegOrderStatusHistory orderHistory = new LastlegOrderStatusHistory(); orderHistory.setId(IdWorker.getId());
orderHistory.setCustomerId(customerId); orderHistory.setOrderId(linked.getId()); orderHistory.setFromStatus(PENDING_REVIEW);
orderHistory.setToStatus(next); orderHistory.setEventId("CARGO_DISCARD_REVIEW:" + application.getId()); orderHistory.setRemark(historyRemark); orderHistory.setCreatedAt(reviewedAt);
orderHistories.insert(orderHistory);
}
history(customerId, application.getId(), PENDING_REVIEW, next, operatorId, historyRemark, reviewedAt);
return detail(customerId, discardNo);
}
@Transactional
public CargoDiscardResponses.Detail complete(String customerId, String discardNo, String operatorId) {
CargoDiscardApplication application = applications.lockNo(customerId, discardNo);
if (application == null) throw notFound("Cargo discard application not found");
if (COMPLETED.equals(application.getStatus())) return detail(customerId, discardNo);
if (!PDA_COMPLETED.equals(application.getStatus())) throw conflict("全部托盘 PDA 作业完成后才能确认废弃完成");
updateLinkedStatus(customerId, application, PDA_COMPLETED, COMPLETED, operatorId, "仓库确认废弃完成");
return detail(customerId, discardNo);
}
public void updateLinkedStatus(String customerId, CargoDiscardApplication application, String from, String to,
String operatorId, String remark) {
LocalDateTime at = now(); application.setStatus(to); application.setUpdatedAt(at); applications.updateById(application);
LastlegOrder linked = orders.findSource(customerId, "LASTLEG_DISCARD", "CARGO_DISCARD:" + application.getId());
if (linked != null) {
linked.setCurrentStatus(to); linked.setPackageCount(application.getTotalPackages());
linked.setVersion((linked.getVersion() == null ? 0 : linked.getVersion()) + 1); linked.setUpdatedAt(at); orders.updateById(linked);
LastlegOrderStatusHistory h = new LastlegOrderStatusHistory(); h.setId(IdWorker.getId()); h.setCustomerId(customerId); h.setOrderId(linked.getId());
h.setFromStatus(from); h.setToStatus(to); h.setEventId("CARGO_DISCARD_STATUS:" + application.getId() + ":" + to); h.setRemark(remark); h.setCreatedAt(at); orderHistories.insert(h);
}
history(customerId, application.getId(), from, to, operatorId, remark, at);
}
@Transactional
public CargoDiscardResponses.Detail cancel(String customerId, String discardNo, String operatorId, boolean platform) {
CargoDiscardApplication application = applications.lockNo(customerId, discardNo);
if (application == null) throw notFound("Cargo discard application not found");
if (CANCELLED.equals(application.getStatus())) return detail(customerId, discardNo);
if (REJECTED.equals(application.getStatus())) throw conflict("已驳回的废弃申请不能取消");
if (PDA_COMPLETED.equals(application.getStatus()) || COMPLETED.equals(application.getStatus())) throw conflict("PDA 已完成库存扣减,废弃申请不能取消");
if (items.listApplication(customerId, application.getId()).stream().anyMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) {
throw conflict("已有托盘完成废弃作业,不能取消申请");
}
if (!platform && !PENDING_REVIEW.equals(application.getStatus())) {
throw conflict("客户只能取消待审核的废弃申请");
}
List<CargoDiscardCarton> links = discardCartons.listApplication(customerId, application.getId());
release(customerId, links);
LocalDateTime at = now();
String from = application.getStatus();
application.setStatus(CANCELLED); application.setReviewedBy(platform ? operatorId : application.getReviewedBy());
application.setReviewRemark(platform ? "平台取消" : application.getReviewRemark());
application.setUpdatedAt(at); applications.updateById(application);
LastlegOrder order = orders.findSource(customerId, "LASTLEG_DISCARD", "CARGO_DISCARD:" + application.getId());
if (order != null) {
order.setCurrentStatus(CANCELLED); order.setVersion((order.getVersion() == null ? 0 : order.getVersion()) + 1); order.setUpdatedAt(at);
orders.updateById(order);
LastlegOrderStatusHistory h = new LastlegOrderStatusHistory(); h.setId(IdWorker.getId()); h.setCustomerId(customerId); h.setOrderId(order.getId());
h.setFromStatus(from); h.setToStatus(CANCELLED); h.setEventId("CARGO_DISCARD_CANCEL:" + application.getId()); h.setRemark("废弃申请已取消"); h.setCreatedAt(at);
orderHistories.insert(h);
}
history(customerId, application.getId(), from, CANCELLED, operatorId, "废弃申请已取消", at);
return detail(customerId, discardNo);
}
private void reserve(String customerId, CargoDiscardApplication application, LastlegOrder order,
CargoDiscardRequests.Item input, LocalDateTime now) {
String skuCode = input.skuCode().trim();
@@ -54,7 +54,8 @@ public record DispatchPalletCandidate(
fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null, null);
}
public String sourcePalletKey() {
// A plan pallet keeps the same lock identity before and after the physical pallet is received.
return planPalletId != null ? "PLAN:" + planPalletId : "RECEIVED:" + warehousePalletId;
// Once received, the physical pallet is the shared resource even when it contains
// allocations from multiple inbound orders/plan-pallet rows.
return warehousePalletId != null ? "RECEIVED:" + warehousePalletId : "PLAN:" + planPalletId;
}
}
@@ -637,8 +637,9 @@ public class DispatchService {
private DispatchPalletLock activeLock(String customerId, DispatchPalletCandidate candidate) {
DispatchPalletLock active = locks.lockActive(customerId, candidate.sourcePalletKey());
if (active == null && candidate.warehousePalletId() != null) {
active = locks.lockActive(customerId, "RECEIVED:" + candidate.warehousePalletId());
if (active == null && candidate.warehousePalletId() != null && candidate.planPalletId() != null) {
// Read legacy plan-level locks created before physical-pallet locking was introduced.
active = locks.lockActive(customerId, "PLAN:" + candidate.planPalletId());
}
return active;
}
@@ -64,4 +64,9 @@ public interface InventoryBalanceMapper extends BaseMapper<InventoryBalance> {
"<if test='overseasWarehouseId != null and overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{overseasWarehouseId}</if></script>")
InventorySummary summarize(@Param("customerId") String customerId,
@Param("overseasWarehouseId") String overseasWarehouseId);
@Update("UPDATE ll_inventory_balance SET available_packages=available_packages-#{packages},version=version+1,updated_at=NOW(3) " +
"WHERE customer_id=#{customerId} AND id=#{balanceId} AND available_packages>=#{packages}")
int deductAvailable(@Param("customerId") String customerId, @Param("balanceId") Long balanceId,
@Param("packages") int packages);
}
@@ -6,6 +6,7 @@ import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@@ -31,4 +32,10 @@ public interface InventoryPositionMapper extends BaseMapper<InventoryPosition> {
@Param("balanceId") Long balanceId,
@Param("palletId") Long palletId,
@Param("batchNo") String batchNo);
@Update("UPDATE ll_inventory_position SET available_packages=available_packages-#{packages}," +
"status=CASE WHEN available_packages-#{packages}=0 THEN 'EMPTY' ELSE status END,version=version+1,updated_at=NOW(3) " +
"WHERE customer_id=#{customerId} AND id=#{positionId} AND available_packages>=#{packages}")
int deductAvailable(@Param("customerId") String customerId, @Param("positionId") Long positionId,
@Param("packages") int packages);
}
@@ -51,6 +51,14 @@ public class MerchantRelabelApplicationController {
@PathVariable String relabelNo, @RequestPart("file") MultipartFile file) {
return ApiResponse.success(service.uploadShippingLabel(customer(user), relabelNo, operatorLong(user), file));
}
@PostMapping(value = "/{relabelNo}/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<RelabelResponses.Detail> uploadFile(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @RequestParam String scope, @RequestParam(required = false) Long itemId,
@RequestPart("file") MultipartFile file) {
String kind = "SKU_TOTAL".equalsIgnoreCase(scope) ? "SKU_TOTAL" : "CARTON_TOTAL";
if ("ITEM".equalsIgnoreCase(scope)) return ApiResponse.success(service.uploadItemFile(customer(user), relabelNo, itemId == null ? 0L : itemId, operatorLong(user), file));
return ApiResponse.success(service.uploadTotalFile(customer(user), relabelNo, operatorLong(user), file, kind));
}
@GetMapping("/{relabelNo}/shipping-label/{fileId}/download")
public ResponseEntity<byte[]> downloadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @PathVariable long fileId) {
@@ -12,6 +12,8 @@ public final class RelabelResponses {
String contactName, String contactPhone, String status, int itemCount,
int totalPackageCount, int notifiedPackageCount, String skuCodes,
String totalFileId, String totalFileName, String totalFileUrl,
String cartonTotalFileId, String cartonTotalFileName, String cartonTotalFileUrl,
String skuTotalFileId, String skuTotalFileName, String skuTotalFileUrl,
BigDecimal chargeAmount, String chargeCurrency, String walletStatus,
String walletErrorMessage, String deliveryMethod, boolean printShippingLabel,
String shippingLabelFileId, String shippingLabelFileName, String shippingLabelFileUrl,
@@ -33,6 +33,12 @@ public class RelabelApplication {
private String totalFileId;
private String totalFileName;
private String totalFileUrl;
private String cartonTotalFileId;
private String cartonTotalFileName;
private String cartonTotalFileUrl;
private String skuTotalFileId;
private String skuTotalFileName;
private String skuTotalFileUrl;
private String status;
private Integer itemCount;
private Integer totalPackageCount;
@@ -98,11 +98,19 @@ public class RelabelApplicationService implements WalletResultHandler {
@Transactional
public RelabelResponses.Detail uploadTotalFile(String customerId, String relabelNo, long operatorId, MultipartFile file) {
return uploadTotalFile(customerId, relabelNo, operatorId, file, "TOTAL");
}
@Transactional
public RelabelResponses.Detail uploadTotalFile(String customerId, String relabelNo, long operatorId, MultipartFile file, String kind) {
RelabelApplication application = lockForDocument(customerId, relabelNo);
requirePdf(file);
var uploaded = documentFiles.upload(application.getCustomerId(), "OVERSEAS_RELABEL_TOTAL_DOCUMENT", String.valueOf(application.getId()), operatorId, file);
application.setTotalFileId(String.valueOf(uploaded.fileId())); application.setTotalFileName(uploaded.fileName());
application.setTotalFileUrl(documentUrl(relabelNo, uploaded.fileId(), null)); application.setUpdatedAt(LocalDateTime.now());
String businessType = "SKU_TOTAL".equals(kind) ? "OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT" : "OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT";
var uploaded = documentFiles.upload(application.getCustomerId(), businessType, String.valueOf(application.getId()), operatorId, file);
String fileId = String.valueOf(uploaded.fileId()); String url = documentUrl(relabelNo, uploaded.fileId(), null);
if ("SKU_TOTAL".equals(kind)) { application.setSkuTotalFileId(fileId); application.setSkuTotalFileName(uploaded.fileName()); application.setSkuTotalFileUrl(url); }
else { application.setCartonTotalFileId(fileId); application.setCartonTotalFileName(uploaded.fileName()); application.setCartonTotalFileUrl(url); }
application.setTotalFileId(fileId); application.setTotalFileName(uploaded.fileName()); application.setTotalFileUrl(url); application.setUpdatedAt(LocalDateTime.now());
applications.updateById(application);
return detail(application.getCustomerId(), relabelNo);
}
@@ -708,6 +716,8 @@ public class RelabelApplicationService implements WalletResultHandler {
Objects.requireNonNullElse(value.getTotalPackageCount(), 0),
Objects.requireNonNullElse(value.getNotifiedPackageCount(), 0), skuCodes,
value.getTotalFileId(), value.getTotalFileName(), value.getTotalFileUrl(),
value.getCartonTotalFileId(), value.getCartonTotalFileName(), value.getCartonTotalFileUrl(),
value.getSkuTotalFileId(), value.getSkuTotalFileName(), value.getSkuTotalFileUrl(),
value.getChargeAmount(), value.getChargeCurrency(), value.getWalletStatus(), value.getWalletErrorMessage(),
value.getDeliveryMethod(), Boolean.TRUE.equals(value.getPrintShippingLabel()),
value.getShippingLabelFileId(), value.getShippingLabelFileName(), value.getShippingLabelFileUrl(),
@@ -534,7 +534,10 @@ public class LastlegOrderService {
String warehouse = StringUtils.hasText(query.warehouse()) ? query.warehouse().trim() : null;
return new LambdaQueryWrapper<LastlegOrder>()
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.orderNo()), LastlegOrder::getOrderNo, query.orderNo())
.and(StringUtils.hasText(query.orderNo()), nested -> nested
.like(LastlegOrder::getOrderNo, query.orderNo())
.or()
.like(LastlegOrder::getSourceOrderNo, query.orderNo()))
.like(StringUtils.hasText(relabelNo), LastlegOrder::getRelabelOrderNo, relabelNo)
.eq(StringUtils.hasText(query.orderType()), LastlegOrder::getOrderType, query.orderType())
.like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode())
@@ -590,7 +593,7 @@ public class LastlegOrderService {
private LastlegOrderSummary summary(LastlegOrder o, List<LastlegOrderMapper.PalletMergeRelationRow> relations,
String containerNode, String dispatchStatus) {
List<LastlegOrderMapper.PalletMergeRelationRow> related = relations == null ? List.of() : relations;
return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(),
return new LastlegOrderSummary(o.getId(), displayOrderNo(o), o.getOrderType(),
LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(),
displayInboundCode(o), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(),
o.getOverseasWarehouseCode(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
@@ -606,6 +609,11 @@ public class LastlegOrderService {
o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt());
}
static String displayOrderNo(LastlegOrder order) {
return "WAREHOUSE_TRANSFER".equals(order.getOrderType()) && StringUtils.hasText(order.getSourceOrderNo())
? order.getSourceOrderNo() : order.getOrderNo();
}
private String containerNode(LastlegOrder order) {
if (!StringUtils.hasText(order.getSourceContainerBookingId())) return null;
ContainerShippingGate gate = containerShippingGates.selectById(order.getSourceContainerBookingId());
@@ -1530,6 +1530,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE transfer_estimated_ship_stmt FROM @ddl; EXECUTE transfer_estimated_ship_stmt; DEALLOCATE PREPARE transfer_estimated_ship_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='label_deadline'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN label_deadline DATETIME(3) NULL COMMENT ''换标截止时间'', ADD COLUMN relabel_contact_name VARCHAR(128) NULL COMMENT ''换标联系人'', ADD COLUMN relabel_contact_phone VARCHAR(64) NULL COMMENT ''换标联系电话'', ADD COLUMN relabel_charge_amount DECIMAL(20,2) NULL COMMENT ''换标费用CNY'', ADD COLUMN relabel_delivery_method VARCHAR(32) NULL COMMENT ''换标后派送方式'''));
PREPARE transfer_relabel_config_stmt FROM @ddl; EXECUTE transfer_relabel_config_stmt; DEALLOCATE PREPARE transfer_relabel_config_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='relabel_completed_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT ''关联贴标完成时间'' AFTER relabel_no'));
PREPARE transfer_relabel_completed_stmt FROM @ddl; EXECUTE transfer_relabel_completed_stmt; DEALLOCATE PREPARE transfer_relabel_completed_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='palletize_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN palletize_idempotency_key VARCHAR(255) NULL COMMENT ''PDA打托幂等键'', ADD COLUMN palletize_request_hash CHAR(64) NULL COMMENT ''PDA打托请求哈希'', ADD COLUMN confirmation_idempotency_key VARCHAR(255) NULL COMMENT ''客户测量确认幂等键'', ADD COLUMN confirmation_request_hash CHAR(64) NULL COMMENT ''客户测量确认请求哈希'', ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认测量时间'''));
PREPARE transfer_fulfillment_stmt FROM @ddl; EXECUTE transfer_fulfillment_stmt; DEALLOCATE PREPARE transfer_fulfillment_stmt;
@@ -1920,7 +1922,9 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NOT NULL COMMENT '托盘档案ID',
sku_inventory_id BIGINT NOT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称',
available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '废弃箱数',
available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '申请废弃箱数',
actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/COMPLETED',
pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code),
KEY idx_ll_cargo_discard_item_list(customer_id,application_id),
@@ -1943,6 +1947,20 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history (
KEY idx_ll_cargo_discard_history(customer_id,application_id,created_at)
) ENGINE=InnoDB COMMENT='货物废弃审批历史';
-- Backfill applications created before cargo discard became a first-class tail-leg order.
INSERT IGNORE INTO ll_order (
id,order_no,customer_id,order_type,inbound_code,inbound_order_no,source_region,source_inbound_id,
source_order_id,source_order_no,overseas_warehouse_id,overseas_warehouse_name,package_count,
current_status,version,source_event_id,source_loading_plan_id,source_payload_hash,created_at,updated_at
)
SELECT a.id,a.discard_no,a.customer_id,'CARGO_DISCARD',a.inbound_codes,a.discard_no,'LASTLEG_DISCARD',
CONCAT('CARGO_DISCARD:',a.id),CAST(a.id AS CHAR),a.discard_no,a.overseas_warehouse_id,a.overseas_warehouse_name,
a.total_packages,a.status,0,CONCAT('CARGO_DISCARD:',a.id),'CARGO_DISCARD',a.request_hash,a.created_at,a.updated_at
FROM ll_cargo_discard_application a
LEFT JOIN ll_order o ON o.customer_id=a.customer_id AND o.source_region='LASTLEG_DISCARD'
AND o.source_inbound_id=CONCAT('CARGO_DISCARD:',a.id)
WHERE o.id IS NULL;
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
customer_name VARCHAR(255) NULL COMMENT '客户名称快照', order_no VARCHAR(64) NOT NULL COMMENT '订单号',
@@ -14,6 +14,7 @@ import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
@@ -48,6 +49,7 @@ class CargoDiscardServiceTest {
@Mock CargoDiscardInventoryMapper inventory;
@Mock CargoDiscardInventoryAdjustmentService adjustments;
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
private CargoDiscardService service;
private final AtomicReference<CargoDiscardApplication> storedApplication = new AtomicReference<>();
@@ -66,6 +68,7 @@ class CargoDiscardServiceTest {
ReflectionTestUtils.setField(service, "adjustments", adjustments);
ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator());
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "feePerPackage", new BigDecimal("5.00"));
lenient().when(applications.insert(any(CargoDiscardApplication.class))).thenAnswer(invocation -> { storedApplication.set(invocation.getArgument(0)); return 1; });
@@ -100,7 +103,7 @@ class CargoDiscardServiceTest {
}
@Test
void approvalDeductsSkuInventoryAndWritesOneLedgerPerCarton() {
void approvalPushesToPdaWithoutDeductingInventory() {
CargoDiscardApplication application = pendingApplication();
storedApplication.set(application);
CargoDiscardItem item = item();
@@ -108,15 +111,12 @@ class CargoDiscardServiceTest {
storedLinks.add(link(item.getId(), 1L));
storedLinks.add(link(item.getId(), 2L));
when(applications.lockNo("100", "FH-1")).thenReturn(application);
when(adjustments.discard(eq("100"), eq(application), any(), any(), eq("9"), any()))
.thenReturn(new CargoDiscardInventoryAdjustmentService.AdjustmentResult(3, 2, 1));
var detail = service.review("100", "FH-1", "9", new CargoDiscardRequests.Review("APPROVE", "ok"));
assertEquals(CargoDiscardService.APPROVED, detail.summary().status());
verify(adjustments).discard(eq("100"), eq(application), any(), any(), eq("9"), any());
assertEquals("ok; inventoryBefore=3, discarded=2, inventoryAfter=1",
storedHistories.get(storedHistories.size() - 1).getRemark());
verify(adjustments, never()).discard(any(), any(), any(), any(), any(), any());
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
assertEquals("ok", storedHistories.get(storedHistories.size() - 1).getRemark());
}
@Test
@@ -412,6 +412,15 @@ class DispatchServiceTest {
verify(locks, org.mockito.Mockito.times(1)).selectList(any());
}
@Test
void receivedPalletUsesPhysicalPalletAsDispatchLockIdentity() {
DispatchPalletCandidate mergedInboundOrder = candidate(10L, 900L, "ONT8");
DispatchPalletCandidate plannedOnly = candidate(11L, null, "ONT8");
assertEquals("RECEIVED:900", mergedInboundOrder.sourcePalletKey());
assertEquals("PLAN:11", plannedOnly.sourcePalletKey());
}
private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) {
return candidate(planPalletId, warehousePalletId, destination, 12);
}
@@ -69,6 +69,16 @@ class LastlegOrderServiceTest {
@Mock ContainerShippingGateMapper containerShippingGates;
LastlegOrderService service;
@Test
void exposesWarehouseTransferNumberAsTailOrderNumber() {
LastlegOrder order = new LastlegOrder();
order.setOrderNo("WT-WH-20260911-020226");
order.setOrderType("WAREHOUSE_TRANSFER");
order.setSourceOrderNo("WT-WH-20260911-020225");
assertEquals("WT-WH-20260911-020225", LastlegOrderService.displayOrderNo(order));
}
@BeforeEach
void setUp() {
service = new LastlegOrderService();
@@ -0,0 +1,20 @@
USE baiyue_lastleg;
ALTER TABLE ll_cargo_discard_item
ADD COLUMN actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数' AFTER discard_packages,
ADD COLUMN pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/COMPLETED' AFTER actual_discard_packages,
ADD COLUMN pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人' AFTER pda_status,
ADD COLUMN pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间' AFTER pda_operator_id;
INSERT IGNORE INTO ll_order (
id,order_no,customer_id,order_type,inbound_code,inbound_order_no,source_region,source_inbound_id,
source_order_id,source_order_no,overseas_warehouse_id,overseas_warehouse_name,package_count,
current_status,version,source_event_id,source_loading_plan_id,source_payload_hash,created_at,updated_at
)
SELECT a.id,a.discard_no,a.customer_id,'CARGO_DISCARD',a.inbound_codes,a.discard_no,'LASTLEG_DISCARD',
CONCAT('CARGO_DISCARD:',a.id),CAST(a.id AS CHAR),a.discard_no,a.overseas_warehouse_id,a.overseas_warehouse_name,
a.total_packages,a.status,0,CONCAT('CARGO_DISCARD:',a.id),'CARGO_DISCARD',a.request_hash,a.created_at,a.updated_at
FROM ll_cargo_discard_application a
LEFT JOIN ll_order o ON o.customer_id=a.customer_id AND o.source_region='LASTLEG_DISCARD'
AND o.source_inbound_id=CONCAT('CARGO_DISCARD:',a.id)
WHERE o.id IS NULL;
+12
View File
@@ -3893,6 +3893,18 @@ PREPARE relabel_inventory_stmt FROM @ddl; EXECUTE relabel_inventory_stmt; DEALLO
-- 2026-09-09 / 换标PDA完工、客户确认及出库分流
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='delivery_method'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填'' AFTER contact_phone'));
PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='carton_total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_id VARCHAR(128) NULL COMMENT ''箱唛总文件ID'' AFTER total_file_url'));
PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='carton_total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_name VARCHAR(255) NULL COMMENT ''箱唛总文件名'' AFTER carton_total_file_id'));
PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='carton_total_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_url VARCHAR(1000) NULL COMMENT ''箱唛总文件地址'' AFTER carton_total_file_name'));
PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='sku_total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN sku_total_file_id VARCHAR(128) NULL COMMENT ''SKU总文件ID'' AFTER carton_total_file_url'));
PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='sku_total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN sku_total_file_name VARCHAR(255) NULL COMMENT ''SKU总文件名'' AFTER sku_total_file_id'));
PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='sku_total_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN sku_total_file_url VARCHAR(1000) NULL COMMENT ''SKU总文件地址'' AFTER sku_total_file_name'));
PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='print_shipping_label'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要打印面单'' AFTER delivery_method'));
PREPARE relabel_fulfillment_stmt FROM @ddl; EXECUTE relabel_fulfillment_stmt; DEALLOCATE PREPARE relabel_fulfillment_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_id VARCHAR(128) NULL COMMENT ''客户上传面单文件ID'' AFTER print_shipping_label'));
@@ -0,0 +1,14 @@
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='carton_total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_id VARCHAR(128) NULL COMMENT ''箱唛总文件ID'' AFTER total_file_url'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='carton_total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_name VARCHAR(255) NULL COMMENT ''箱唛总文件名'' AFTER carton_total_file_id'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='carton_total_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_url VARCHAR(1000) NULL COMMENT ''箱唛总文件地址'' AFTER carton_total_file_name'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='sku_total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN sku_total_file_id VARCHAR(128) NULL COMMENT ''SKU总文件ID'' AFTER carton_total_file_url'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='sku_total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN sku_total_file_name VARCHAR(255) NULL COMMENT ''SKU总文件名'' AFTER sku_total_file_id'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='sku_total_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN sku_total_file_url VARCHAR(1000) NULL COMMENT ''SKU总文件地址'' AFTER sku_total_file_name'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;