This commit is contained in:
2026-10-29 15:49:18 +08:00
parent 849f8dc5f2
commit 0bd25de906
23 changed files with 293 additions and 16 deletions
@@ -8467,3 +8467,9 @@ USE baiyue_iam;
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5117200,0,'PLATFORM','MENU',5105000,'platform:overseas-fee:list','海外费用明细','/overseas-business/fees','GET',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5117200,0,CURRENT_TIMESTAMP(3));
SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables;
USE baiyue_lastleg;
-- 现场扫描清单独立于客户选箱快照;重复执行不改现有业务数据。
SET @discard_actual_marks_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='actual_carton_marks_json'), 'SELECT 1', 'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_carton_marks_json JSON NULL COMMENT ''PDA现场实际废弃箱唛清单,与客户选箱快照独立保存''');
PREPARE discard_actual_marks_stmt FROM @discard_actual_marks_ddl;
EXECUTE discard_actual_marks_stmt;
DEALLOCATE PREPARE discard_actual_marks_stmt;
+2
View File
@@ -171,3 +171,5 @@
- 除仓租外,换标报价、外接约定运费、拆柜打托成本仅保留业务依据,不再自动生成客户应收。旧派送应收参数须改由海外费用登记审核,供应商应付不变。订单详情新增费用统一委托人工费用服务。
- 新换标资金状态固定 NOT_REQUIRED,完成不会发布旧自动应收;旧已冻结申请保留原资金收尾,仅在确认实扣后发布已收来源。已有旧来源只补扣款引用,不重复生成来源;旧财务账单需上线前核对撤销或核销。采购简化流程复用海外人工费用来源,默认余额支付;资金冻结、审核实扣与回执保持原有幂等和客户隔离。
- 同一服务业务的旧自动项目已锁款或已有有效应收时,拦截相同项目的人工重复登记;人工链允许按实际服务合法分次登记。
- 废弃PDA:无箱唛关联的普通库存先扫本单托盘,再扫描现场箱唛自动累计各明细实际箱数,禁止手填数量绕过扫码。现场箱唛独立保存到 actual_carton_marks_json,不改客户选箱快照;提交校验数量一致、同托及同单跨托盘不重复,失败不落盘、可刷新重扫。客户/平台详情返回 actualCartonMarks、palletNo、实际箱数和原备注。数量申请达到目标后释放未用预留。
@@ -17,7 +17,10 @@ public final class CargoDiscardRequests {
public record Create(@NotEmpty @Size(max = 200) List<@Valid Item> items,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
@Size(max = 1000) String remark, @Positive Integer requestedPackages) {
/** 逐箱申请从明细计数,无箱唛申请另传整单目标箱数。 */
public Create(List<Item> items, String idempotencyKey, String remark) { this(items, idempotencyKey, remark, null); }
}
public record Item(Long orderId, Long returnCartonId, @NotBlank @Size(max = 128) String skuCode,
@Positive int discardPackages, Long inventoryPositionId, @Size(max = 5000) List<@NotNull @Positive Long> cartonIds) {
@@ -42,7 +42,12 @@ public final class CargoDiscardResponses {
public record Item(Long id, Long orderId, String inboundCode, String skuCode,
String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks,
String quantityUnit, Integer actualDiscardPackages, String pdaStatus) {
String quantityUnit, Integer actualDiscardPackages, String pdaStatus,
List<String> actualCartonMarks, String palletNo) {
/** 保留历史详情调用,未记录的现场箱唛和托盘不补造。 */
public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks, String quantityUnit, Integer actualDiscardPackages, String pdaStatus) {
this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, cartonMarks, quantityUnit, actualDiscardPackages, pdaStatus, List.of(), null);
}
/** 无来源事实的调用不猜测数量单位或实际完成数。 */
public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks) {
this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, cartonMarks, null, null, null);
@@ -18,6 +18,7 @@ public class CargoDiscardItem {
private Long returnCartonId;
private Long inventoryPositionId;
private String cartonMarksJson;
private String actualCartonMarksJson;
private Boolean cartonScanRequired;
private String inboundCode;
private Long profileId;
@@ -6,6 +6,17 @@ import java.util.List;
/** 废弃单汇总使用物理箱数,退货明细的数量仍保留为 SKU 件数。 */
public final class CargoDiscardQuantities {
/** 明细保留候选托盘预留容量,整单总箱数小于容量时按客户目标跨托盘分配。 */
public static boolean quantityPool(com.baiyue.lastleg.discard.entity.CargoDiscardApplication application, List<CargoDiscardItem> lines) {
return application.getTotalPackages() != null && !lines.isEmpty()
&& lines.stream().allMatch(i -> "INVENTORY".equals(i.getSourceType()) && i.getInventoryPositionId() != null
&& !Boolean.TRUE.equals(i.getCartonScanRequired()) && (i.getCartonMarksJson() == null || "[]".equals(i.getCartonMarksJson())))
&& application.getTotalPackages() < boxes(lines, false);
}
/** 客户申请箱数与候选库存容量分别统计。 */
public static int requested(com.baiyue.lastleg.discard.entity.CargoDiscardApplication application, List<CargoDiscardItem> lines) {
return lines.isEmpty() || quantityPool(application, lines) ? application.getTotalPackages() : boxes(lines, false);
}
/** 数量规则不需要实例化。 */
private CargoDiscardQuantities() { }
@@ -13,7 +13,11 @@ public final class PdaCargoDiscardDtos {
public record Scan(@NotBlank @Size(max = 128) String palletNo, @Size(max = 128) String discardNo) {
public Scan(String palletNo) { this(palletNo, null); }
}
public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages) { }
public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages,
List<@NotBlank @Size(max = 128) String> scannedCartonMarks) {
/** 已关联箱唛的旧调用继续使用整托扫码清单。 */
public ActualLine(Long itemId, int actualPackages) { this(itemId, actualPackages, null); }
}
public record Confirm(@NotEmpty List<@Valid ActualLine> lines,
@NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds,
List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons,
@@ -29,5 +33,6 @@ public final class PdaCargoDiscardDtos {
boolean actualQuantityEditable) { }
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo,
String scanMode, List<String> cartonMarks) { }
String scanMode, List<String> cartonMarks, String remark,
Integer requestedPackages, Integer remainingPackages) { }
}
@@ -76,12 +76,27 @@ public class PdaCargoDiscardService {
throw conflict("必须扫描本次废弃的全部箱号");
}
List<CargoDiscardItem> palletItems = new ArrayList<>();
// 无箱唛关联的库存按现场逐箱扫码计数,箱唛仅记录实弃事实,不冒充原箱级关联。
var observed = new HashSet<String>();
var otherMarks = repository.items(application.getCustomerId(), application.getId()).stream()
.filter(i -> "INVENTORY".equals(i.getSourceType()))
.filter(i -> !physicalCode(application, i).equals(task.palletNo()))
.flatMap(i -> stockService.actualCartonMarks(i).stream()).collect(Collectors.toSet());
for (PdaCargoDiscardDtos.Sku line : task.skus()) {
CargoDiscardItem item = repository.item(line.itemId());
if (item == null || !application.getCustomerId().equals(item.getCustomerId())
|| !Objects.equals(application.getId(), item.getApplicationId())
|| !physicalCode(application, item).equals(task.palletNo()))
throw conflict("废弃明细不属于当前申请或托盘");
if (line.actualQuantityEditable()) {
var input = request.lines().stream().filter(i -> i.itemId().equals(line.itemId())).findFirst().orElseThrow();
var scanned = input.scannedCartonMarks() == null ? List.<String>of() : input.scannedCartonMarks();
if (scanned.size() != input.actualPackages() || scanned.stream().anyMatch(mark -> mark == null || mark.isBlank()
|| mark.length() > 128 || !mark.equals(mark.trim()) || !observed.add(mark) || otherMarks.contains(mark)))
throw conflict("实际废弃箱数必须与逐箱扫码一致,箱唛不能重复或跨托盘重复废弃");
if ("COMPLETED".equals(item.getPdaStatus()) && !new HashSet<>(stockService.actualCartonMarks(item)).equals(new HashSet<>(scanned)))
throw conflict("已完成托盘的实际废弃箱唛不能修改");
}
if ("COMPLETED".equals(item.getPdaStatus())
&& !Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId())))
throw conflict("已完成托盘的实际数量不能修改");
@@ -92,6 +107,18 @@ public class PdaCargoDiscardService {
|| !application.getCustomerId().equals(item.getCustomerId())
|| !Objects.equals(application.getId(), item.getApplicationId())))
throw conflict("废弃申请终态存在未完成明细,不能恢复作业结果");
if (terminal && palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task;
var allItems = repository.items(application.getCustomerId(), application.getId());
boolean quantityPool = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.quantityPool(application, allItems);
if (quantityPool) {
int done = allItems.stream().filter(i -> "COMPLETED".equals(i.getPdaStatus()))
.mapToInt(i -> Objects.requireNonNullElse(i.getActualDiscardPackages(), 0)).sum();
int current = actuals.values().stream().mapToInt(Integer::intValue).sum();
int availableLater = allItems.stream().filter(i -> !"COMPLETED".equals(i.getPdaStatus()) && !actuals.containsKey(i.getId()))
.mapToInt(CargoDiscardItem::getDiscardPackages).sum();
if ((long)done + current > application.getTotalPackages()) throw conflict("累计废弃箱数不能超过客户申请箱数,请刷新核对剩余数量");
if ((long)done + current + availableLater < application.getTotalPackages()) throw conflict("剩余托盘数量不足以完成申请,请核对本托盘实际废弃箱数");
}
photoService.confirm(application, task.palletNo(), request.photoFileIds(),
palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus())));
// 已完成托盘保持原始实点结果,终态重放不得再次扣减或释放库存。
@@ -114,12 +141,25 @@ public class PdaCargoDiscardService {
adjustments.discardPalletItem(application.getCustomerId(), application, item, links,
actuals.get(item.getId()), identity.operatorKey(), now);
}
var actualMarks = task.skus().stream().filter(s -> s.itemId().equals(item.getId())).findFirst().orElseThrow().actualQuantityEditable()
? request.lines().stream().filter(i -> i.itemId().equals(item.getId())).findFirst().orElseThrow().scannedCartonMarks()
: cartonMarks(application, item);
if ("INVENTORY".equals(item.getSourceType())) stockService.saveActualCartonMarks(item, actualMarks == null ? List.of() : actualMarks);
item.setActualDiscardPackages(actuals.get(item.getId())); item.setPdaStatus("COMPLETED");
item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); repository.saveItem(item);
}
task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).map(repository::item)
.filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId).filter(Objects::nonNull).distinct()
.forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId));
// 达到客户申请总数后无需逐个空扫其余托盘,仅释放未使用预留,不扣库存、不伪造照片。
allItems = repository.items(application.getCustomerId(), application.getId());
if (quantityPool && com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(allItems, true) == application.getTotalPackages()) {
for (var unused : allItems) if (!"COMPLETED".equals(unused.getPdaStatus())) {
stockService.discard(application.getCustomerId(), identity.operatorKey(), application, unused, 0);
unused.setActualDiscardPackages(0); unused.setPdaStatus("COMPLETED");
unused.setPdaOperatorId(identity.operatorKey()); unused.setPdaCompletedAt(now); repository.saveItem(unused);
}
}
if (repository.items(application.getCustomerId(), application.getId()).stream()
.allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) {
int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(
@@ -186,7 +226,10 @@ public class PdaCargoDiscardService {
if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛");
return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(),
entry.getKey(), first.getInventoryPositionId() != null ? first.getInventoryPositionId() : isReturnItem(first) ? first.getReturnCartonId() : first.getProfileId(),
status, skus, outboundService.outboundNo(application), cartonScan ? "CARTON" : "PALLET", marks);
status, skus, outboundService.outboundNo(application), cartonScan ? "CARTON" : "PALLET", marks,
application.getRemark(), com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(application, allItems),
application.getTotalPackages() == null ? null : Math.max(0, application.getTotalPackages()
- com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(allItems, true)));
}).toList();
}
/** 相关托盘只从客户库存事实读取,不从请求推断。 */
@@ -15,6 +15,6 @@ public class CargoDiscardQuantityRepository {
public int boxes(CargoDiscardApplication application) {
var lines = items.listApplication(application.getCustomerId(), application.getId());
boolean completed = "PDA_COMPLETED".equals(application.getStatus()) || "COMPLETED".equals(application.getStatus());
return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, completed);
return lines.isEmpty() ? application.getTotalPackages() : completed ? CargoDiscardQuantities.boxes(lines, true) : CargoDiscardQuantities.requested(application, lines);
}
}
@@ -51,7 +51,7 @@ public class CargoDiscardOutboundService {
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND"));
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE);
outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo());
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false));
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(application, lines));
outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application));
outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO);
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(at); outbound.setUpdatedAt(at);
@@ -67,7 +67,8 @@ public class CargoDiscardOutboundService {
var position = stockService.position(application.getCustomerId(), line.getInventoryPositionId());
if (!application.getOverseasWarehouseId().equals(position.getOverseasWarehouseId())) throw conflict("废弃库存仓库不匹配");
palletNo = position.getPalletNo();
cartonMarks = String.join(",", stockService.cartonMarks(line));
cartonMarks = String.join(",", line.getActualCartonMarksJson() == null
? stockService.cartonMarks(line) : stockService.actualCartonMarks(line));
} else if (returned) {
var carton = returnCartons.selectById(line.getReturnCartonId());
if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())
@@ -134,7 +135,8 @@ public class CargoDiscardOutboundService {
};
outbound.setStatus(status);
outbound.setLxCodes(null);
outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, "OUTBOUNDED".equals(status)));
outbound.setPackageCount("OUTBOUNDED".equals(status) ? com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, true)
: com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(application, lines));
outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound);
}
/** 保留逐 SKU 来源行,已投影过的同一实箱在后续行记零箱。 */
@@ -147,6 +147,8 @@ public class CargoDiscardService {
CargoDiscardRequests.Create request) {
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
if (request.requestedPackages() != null && !StringUtils.hasText(request.remark()))
throw badRequest("无箱唛数量废弃必须填写备注");
CargoDiscardApplication replay = applications.findKey(customerId, key);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) {
@@ -219,6 +221,13 @@ public class CargoDiscardService {
}
total = Math.addExact(total, returnCartonCache.size());
total = Math.addExact(total, requestedBoxes.size());
// 客户只指定总箱数,托盘候选预留后由仓库分配实际数量。
if (request.requestedPackages() != null) {
if (request.requestedPackages() <= 0 || request.requestedPackages() > total
|| request.items().stream().anyMatch(i -> !stockService.quantitySelectionAllowed(customerId, i)))
throw badRequest("按数量废弃仅支持有托盘且无箱唛关联的库存,申请箱数不能超过可用库存");
total = request.requestedPackages();
}
LocalDateTime now = LocalDateTime.now();
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
CargoDiscardApplication application = new CargoDiscardApplication();
@@ -289,7 +298,9 @@ public class CargoDiscardService {
List<CargoDiscardResponses.Item> itemViews = items.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(),
value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages(), selectedMarks(customerId, application, value),
value.getReturnCartonId() != null ? "件" : "箱", value.getActualDiscardPackages(), value.getPdaStatus()))
value.getReturnCartonId() != null ? "件" : "箱", value.getActualDiscardPackages(), value.getPdaStatus(),
stockService.actualCartonMarks(value), value.getInventoryPositionId() == null ? null
: stockService.position(customerId, value.getInventoryPositionId()).getPalletNo()))
.toList();
List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(),
@@ -522,7 +533,7 @@ public class CargoDiscardService {
int boxes = quantities.boxes(value);
var lines = items.listApplication(value.getCustomerId(), value.getId());
var skuCodes = lines.stream().map(CargoDiscardItem::getSkuCode).filter(s -> s != null && !s.isBlank()).distinct().sorted().toList();
int requested = lines.isEmpty() ? value.getTotalPackages() : com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false);
int requested = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(value, lines);
Integer actual = lines.stream().anyMatch(i -> i.getActualDiscardPackages() != null)
? com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, true) : null;
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
@@ -25,7 +25,7 @@ public class CargoDiscardStatisticsService {
boolean piecesKnown = true, actualPiecesKnown = true, actualKnown = false, palletsKnown = true;
for (var application : applications) {
var lines = dimensions.items(application.getCustomerId(), application.getId());
boxes += lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, false);
boxes += CargoDiscardQuantities.requested(application, lines);
if (lines.isEmpty()) { piecesKnown = false; palletsKnown = false; }
var confirmed = lines.stream().filter(i -> i.getActualDiscardPackages() != null).toList();
if (!confirmed.isEmpty()) { actualKnown = true; actualBoxes += CargoDiscardQuantities.boxes(confirmed, true); }
@@ -44,6 +44,15 @@ public class CargoDiscardStockService {
}).filter(c -> c.availablePackages() > 0).toList();
}
/** 按数量选择仅适用于有托盘且没有完整逐箱关联的普通库存。 */
public boolean quantitySelectionAllowed(String customer, CargoDiscardRequests.Item request) {
if (request.inventoryPositionId() == null || request.cartonIds() != null) return false;
var position = position(customer, request.inventoryPositionId());
var balance = validate(customer, request);
return position.getPalletNo() != null && !position.getPalletNo().isBlank()
&& !completeCartons(cartons(customer, position), available(customer, position, balance));
}
/** 校验客户、仓库、SKU、预留上限与具体箱子的归属。 */
public InventoryBalance validate(String customer, CargoDiscardRequests.Item request) {
var position = position(customer, request.inventoryPositionId());
@@ -63,7 +72,7 @@ public class CargoDiscardStockService {
} else {
if (relabelStock.cartons(customer, position).size() > 0) throw conflict("换标库存必须选择具体箱子");
if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存");
if (request.discardPackages() != available) throw conflict("部分废弃必须选择具体箱子,不能只填写数量");
if (request.discardPackages() != available) throw conflict("无箱唛的候选托盘须完整预留,废弃目标箱数请填写在申请总数中");
}
if (repository.dropshipPosition(customer, position.getId())) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留");
return balance;
@@ -255,6 +264,17 @@ public class CargoDiscardStockService {
try { return objectMapper.readValue(line.getCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}); }
catch (Exception e) { throw new IllegalStateException("Cannot read carton marks", e); }
}
/** 现场扫码单独保存,不改写客户选箱快照或把无关联库存伪装成已锁定实箱。 */
public void saveActualCartonMarks(CargoDiscardItem line, List<String> marks) {
try { line.setActualCartonMarksJson(objectMapper.writeValueAsString(marks)); }
catch (Exception e) { throw new IllegalStateException("实际废弃箱唛保存失败", e); }
}
/** 废弃详情读取真实提交的现场箱唛,历史未记录时返回空。 */
public List<String> actualCartonMarks(CargoDiscardItem line) {
if (line.getActualCartonMarksJson() == null) return List.of();
try { return objectMapper.readValue(line.getActualCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}); }
catch (Exception e) { throw new IllegalStateException("实际废弃箱唛读取失败", e); }
}
/** 无 SKU 来源保留未指定标记,不猜测商品。 */
private static String sku(InventoryBalance balance) { return balance.getSkuCode() == null || balance.getSkuCode().isBlank() ? "UNSPECIFIED" : balance.getSkuCode(); }
/** 当前可废弃数量包括已完成并确认的换标实箱,不包含其他业务冻结。 */
@@ -16,6 +16,10 @@ import java.math.BigDecimal;
@Mapper
public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
/** 查询当前客户及海外仓的一件代发入仓码,供贴标、转运来源过滤。 */
@org.apache.ibatis.annotations.Select("SELECT DISTINCT inbound_code FROM ll_order WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND order_type='ONE_CLICK_DROPSHIP' AND inbound_code IS NOT NULL")
java.util.List<String> listDropshipInboundCodes(@org.apache.ibatis.annotations.Param("customerId") String customerId,
@org.apache.ibatis.annotations.Param("warehouseId") String warehouseId);
// Correlate both order identity and customer; never infer a business source from remarks.
// CHAR(128) uses the connection character set/collation on both sides. These keys
// have at most 128 characters; table defaults differ between the fleet and order schemas.
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.relabel.repository;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.springframework.stereotype.Repository;
import java.util.Set;
import java.util.stream.Collectors;
/** 查询贴标、转运共用候选中应排除的一件代发来源。 */
@Repository
public class RelabelSourceRepository {
private final LastlegOrderMapper orders;
public RelabelSourceRepository(LastlegOrderMapper orders) { this.orders = orders; }
/** 按客户及操作仓隔离,使用入仓码关联库存,不按订单号前缀猜测业务类型。 */
public Set<String> dropshipInboundCodes(String customerId, String warehouseId) {
return orders.listDropshipInboundCodes(customerId, warehouseId).stream()
.filter(org.springframework.util.StringUtils::hasText).collect(Collectors.toSet());
}
}
@@ -91,6 +91,7 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private WalletCommandOutboxService walletCommands;
@Resource private InventoryCommandService inventoryCommands;
@Resource private InventoryBalanceMapper inventoryBalances;
@Resource private com.baiyue.lastleg.relabel.repository.RelabelSourceRepository sourceRepository;
@Resource private InventoryPositionMapper inventoryPositions;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
@@ -257,6 +258,11 @@ public class RelabelApplicationService implements WalletResultHandler {
.gt(InventoryBalance::getAvailablePackages, 0)
.like(StringUtils.hasText(inboundCode), InventoryBalance::getInboundCode, trim(inboundCode))
.orderByDesc(InventoryBalance::getUpdatedAt));
// 贴标与仓储转运共用本候选接口,一件代发库存不得进入两处来源下拉。
if (!balances.isEmpty()) {
var dropshipCodes = sourceRepository.dropshipInboundCodes(customerId, operationWarehouseId.trim());
balances = balances.stream().filter(balance -> !dropshipCodes.contains(balance.getInboundCode())).toList();
}
Map<Long, List<InventoryPosition>> positionsByBalance = balances.isEmpty() ? Map.of()
: inventoryPositions.listBalances(customerId, balances.stream().map(InventoryBalance::getId).toList())
.stream().filter(position -> value(position.getAvailablePackages()) > 0)
@@ -476,6 +482,11 @@ public class RelabelApplicationService implements WalletResultHandler {
|| !Objects.equals(balance.getInboundOrderId(), item.sourceInboundOrderId())) {
throw conflict("Selected inventory does not belong to this customer, operation warehouse and inbound order");
}
// 旧页面或直接请求也不能用一件代发库存创建贴标申请。
if (sourceRepository.dropshipInboundCodes(customerId, request.operationWarehouseId().trim())
.contains(balance.getInboundCode())) {
throw conflict("一件代发订单不支持贴标申请");
}
if (!inventoryBalances.cartonBelongsToInbound(customerId, request.operationWarehouseId().trim(),
item.sourceInboundOrderId(), item.cartonMark().trim())) {
throw conflict("当前箱子不存在或不属于当前订单");
@@ -287,6 +287,10 @@ public class WarehouseTransferService {
if (selectedOrders.isEmpty() || (!selectedIds.isEmpty() && !isReturnOrderSource(inboundCode) && selectedOrders.size() != selectedIds.size())) {
throw notFound("One or more selected customer orders do not exist in the current tenant");
}
// 与来源下拉保持一致,旧页面提交的一件代发订单也不得创建仓储转运。
if (selectedOrders.stream().anyMatch(order -> "ONE_CLICK_DROPSHIP".equals(order.getOrderType()))) {
throw conflict("一件代发订单不支持仓储转运");
}
} else {
inboundCode = transferNo;
inboundOrderId = createInboundSnapshot(customerId, transferId, transferNo, request, requestHash, now);
@@ -2898,6 +2898,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID',
carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组',
carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子',
actual_carton_marks_json JSON NULL COMMENT 'PDA现场实际废弃箱唛清单,与客户选箱快照独立保存',
completion_photo_file_ids TEXT NULL COMMENT 'PDA完工照片文件ID JSON数组',
actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED',
pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间',
@@ -87,7 +87,7 @@ paths:
- $ref: '#/components/parameters/PalletNo'
post:
summary: Confirm actual disposal with owned pallet photos
description: PalletTask.skus返回actualQuantityEditable布尔值。仅普通库存未保存具体箱唛时为true,PDA展示相关托盘并要求填写实际废弃箱数;库存和费用按实际数结算,未废弃预留释放。已完成任务相同数量重放不重复扣减,修改数量拒绝。
description: PalletTask.skus返回actualQuantityEditable布尔值。仅普通库存未保存具体箱唛时为true(表示现场扫码计数,不能手填数量),PDA展示备注和相关托盘,扫描现场箱唛自动累计实际废弃箱数;库存和费用按实际数结算,未废弃预留释放。已完成任务相同数量重放不重复扣减,修改数量拒绝。
x-permissions: [lastleg:pda:operate, ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR]
requestBody:
required: true
@@ -105,6 +105,7 @@ paths:
required: [itemId, actualPackages]
properties:
itemId: { type: string, pattern: '^[1-9][0-9]*$' }
scannedCartonMarks: { type: array, uniqueItems: true, items: { type: string, minLength: 1, maxLength: 128 }, description: 无关联库存现场箱唛清单,数量须与actualPackages一致,整单跨托盘不重复,详情以actualCartonMarks回显 }
actualPackages: { type: integer, minimum: 0, description: 无精确箱唛对应关系的普通库存允许0至plannedPackages的实际箱数,剩余预留释放;有具体选箱的明细必须等于plannedPackages }
scannedCartonMarks:
type: array
@@ -38,7 +38,7 @@ class PdaCargoDiscardServiceTest {
private CargoDiscardItem item;
private final PdaWarehouseIdentity identity = new PdaWarehouseIdentity("OP", "operator", 99L);
private final com.baiyue.lastleg.discard.service.CargoDiscardPhotoService photoService = mock(com.baiyue.lastleg.discard.service.CargoDiscardPhotoService.class);
private PdaCargoDiscardDtos.Confirm request(int actual) { return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, actual)), List.of(101L)); }
private PdaCargoDiscardDtos.Confirm request(int actual) { return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, actual, marks(11L, actual))), List.of(101L)); }
@BeforeEach void setup() {
service = new PdaCargoDiscardService();
@@ -162,9 +162,55 @@ class PdaCargoDiscardServiceTest {
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(actual)));
noWrites(); verifyNoInteractions(photoService);
}
/** 数量申请可仅使用一个托盘,也可跨托盘完成,剩余预留只释放一次。 */
@ParameterizedTest @ValueSource(ints = {0, 2})
void quantityTargetUsesChosenPalletsAndReleasesUnusedReservations(int firstActual) {
var stock = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class);
ReflectionTestUtils.setField(service, "stockService", stock); scannedStock(stock);
application.setTotalPackages(5); application.setRemark("只废弃破损外箱"); application.setStatus(CargoDiscardService.APPROVED);
item.setSourceType("INVENTORY"); item.setInventoryPositionId(42L); item.setDiscardPackages(12);
var second = new CargoDiscardItem(); second.setId(12L); second.setCustomerId("CUSTOMER"); second.setApplicationId(1L);
second.setSourceType("INVENTORY"); second.setInventoryPositionId(43L); second.setSkuCode("SKU"); second.setDiscardPackages(10); second.setPdaStatus("PENDING");
when(items.listApplication("CUSTOMER", 1L)).thenAnswer(call -> List.of(item, second));
when(items.selectById(12L)).thenReturn(second);
for (var line : List.of(item, second)) {
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition(); position.setOverseasWarehouseId("99");
position.setPalletNo(line == item ? "PALLET-1" : "PALLET-2");
when(stock.position("CUSTOMER", line.getInventoryPositionId())).thenReturn(position);
when(stock.actualQuantityEditable(line)).thenReturn(true);
}
when(applications.selectList(any())).thenReturn(List.of(application));
var task = service.resolve(identity, "PALLET-2", "CD-1");
assertEquals("只废弃破损外箱", task.remark()); assertEquals(5, task.requestedPackages()); assertEquals(5, task.remainingPackages());
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(6)));
verifyNoInteractions(photoService);
if (firstActual > 0) {
service.confirm(identity, "CD-1", "PALLET-1", request(firstActual));
assertEquals(CargoDiscardService.APPROVED, application.getStatus());
assertEquals(3, service.resolve(identity, "PALLET-2", "CD-1").remainingPackages());
}
var secondRequest = new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(12L, 5-firstActual, marks(12L, 5-firstActual))), List.of(101L));
if (firstActual > 0) assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-2",
new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(12L, 3, List.of("FIELD-11-0", "SECOND-1", "SECOND-2"))), List.of(101L))));
service.confirm(identity, "CD-1", "PALLET-2", secondRequest);
assertEquals(CargoDiscardService.PDA_COMPLETED, application.getStatus()); assertEquals(5, application.getTotalPackages());
verify(stock).discard("CUSTOMER", "OP", application, second, 5-firstActual);
verify(stock).discard("CUSTOMER", "OP", application, item, firstActual);
service.confirm(identity, "CD-1", "PALLET-2", secondRequest);
verify(stock, times(1)).discard("CUSTOMER", "OP", application, second, 5-firstActual);
verifyNoInteractions(adjustments);
}
/** 生成现场扫码样本,各托盘箱唛独立。 */
private List<String> marks(long itemId, int count) { return java.util.stream.IntStream.range(0, count).mapToObj(i -> "FIELD-" + itemId + "-" + i).toList(); }
/** Mock仍执行真实JSON序列化,验证重放时箱唛不可篡改。 */
private void scannedStock(com.baiyue.lastleg.discard.service.CargoDiscardStockService stock) {
ReflectionTestUtils.setField(stock, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper());
doCallRealMethod().when(stock).saveActualCartonMarks(any(), anyList());
when(stock.actualCartonMarks(any())).thenCallRealMethod();
}
private void looseTask() {
var stockService = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class);
ReflectionTestUtils.setField(service, "stockService", stockService);
ReflectionTestUtils.setField(service, "stockService", stockService); scannedStock(stockService);
item.setSourceType("INVENTORY"); item.setInventoryPositionId(42L); item.setDiscardPackages(2);
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition();
position.setId(42L); position.setCustomerId("CUSTOMER"); position.setOverseasWarehouseId("99");
@@ -180,6 +226,7 @@ class PdaCargoDiscardServiceTest {
void unboundPalletUsesActualQuantityForInventoryFeeAndReplay(int actual) {
var stockService = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class);
ReflectionTestUtils.setField(service, "stockService", stockService);
scannedStock(stockService);
item.setSourceType("INVENTORY"); item.setInventoryPositionId(42L); item.setDiscardPackages(3);
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition();
position.setOverseasWarehouseId("99"); position.setPalletNo("PALLET-1");
@@ -190,13 +237,21 @@ class PdaCargoDiscardServiceTest {
var task = service.resolve(identity, "PALLET-1", "CD-1");
assertTrue(task.skus().get(0).actualQuantityEditable()); assertEquals("PALLET", task.scanMode());
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(4)));
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1",
new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 1)), List.of(101L))));
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1",
new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 2, List.of("SAME", "SAME"))), List.of(101L))));
verifyNoInteractions(photoService);
var completed = service.confirm(identity, "CD-1", "PALLET-1", request(actual));
assertEquals(actual, completed.skus().get(0).actualPackages());
assertEquals(actual, application.getTotalPackages());
assertEquals(new BigDecimal("5.00").multiply(BigDecimal.valueOf(actual)), application.getTotalFee());
assertEquals(marks(11L, actual), stockService.actualCartonMarks(item));
service.confirm(identity, "CD-1", "PALLET-1", request(actual));
verify(stockService, times(1)).discard("CUSTOMER", "OP", application, item, actual);
if (actual > 0) assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1",
new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, actual,
java.util.stream.IntStream.range(0, actual).mapToObj(i -> "CHANGED-" + i).toList())), List.of(101L))));
verifyNoInteractions(adjustments);
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(3)));
verify(items, times(1)).updateById(item);
@@ -142,6 +142,31 @@ class RelabelApplicationServiceTest {
assertEquals(0, occupied.cartonAvailablePackages());
assertEquals(2, occupied.skuItems().size(), "处理中库存保留占用信息,但不可再次选择");
}
@Test void sharedCandidatesExcludeDropshipButKeepOrdinaryInbound() {
ReflectionTestUtils.setField(service, "returnCartons", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class));
when(orders.listDropshipInboundCodes("10001", "WH-1")).thenReturn(List.of("DROP-1"));
var excluded = new InventoryBalance(); excluded.setId(1L); excluded.setInboundCode("DROP-1");
var ordinary = new InventoryBalance(); ordinary.setId(2L); ordinary.setInboundCode("NORMAL-1");
when(inventoryBalances.selectList(any())).thenReturn(List.of(excluded, ordinary));
var position = new InventoryPosition(); position.setId(3L); position.setBalanceId(2L); position.setAvailablePackages(4L);
when(inventoryPositions.listBalances("10001", List.of(2L))).thenReturn(List.of(position));
var result = service.candidates("10001", "WH-1", null);
assertEquals(List.of("NORMAL-1"), result.stream().map(RelabelResponses.InventoryCandidate::inboundCode).toList());
}
@Test void staleDropshipSelectionCannotCreateRelabelApplication() {
when(orders.listDropshipInboundCodes("10001", "WH-1")).thenReturn(List.of("CODE-1"));
var error = assertThrows(ResponseStatusException.class,
() -> service.create("10001", "operator-1", request("dropship", 1, 1)));
assertEquals("一件代发订单不支持贴标申请", error.getReason());
verify(applications, never()).insert(any(RelabelApplication.class));
verifyNoInteractions(inventoryCommands);
}
@Test void convertedSkuRemainsAvailableForCartonLabelOnly() {
var cartons = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
var lines = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class);
@@ -245,6 +270,7 @@ class RelabelApplicationServiceTest {
ReflectionTestUtils.setField(service, "walletCommands", walletCommands);
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
ReflectionTestUtils.setField(service, "inventoryBalances", inventoryBalances);
ReflectionTestUtils.setField(service, "sourceRepository", new com.baiyue.lastleg.relabel.repository.RelabelSourceRepository(orders));
ReflectionTestUtils.setField(service, "inventoryPositions", inventoryPositions);
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
ReflectionTestUtils.setField(service, "outboundItems", outboundItems);
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.relabel.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.relabel.repository.RelabelSourceRepository;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import static org.assertj.core.api.Assertions.assertThat;
/** 用真实 Mapper 验证业务类型、客户和仓库隔离,避免跨来源误隐藏。 */
class RelabelSourceRepositoryTest {
@Test void onlyDropshipSourcesInCurrentCustomerAndWarehouseAreExcluded() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:relabel_sources;MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),order_type VARCHAR(64),inbound_code VARCHAR(64))");
jdbc.execute("INSERT INTO ll_order VALUES (1,'C1','W1','ONE_CLICK_DROPSHIP','DROP'),(2,'C1','W1','WAREHOUSE_TRANSFER','NORMAL'),(3,'C2','W1','ONE_CLICK_DROPSHIP','FOREIGN'),(4,'C1','W2','ONE_CLICK_DROPSHIP','OTHER-WAREHOUSE')");
var configuration = new MybatisConfiguration();
configuration.addMapper(LastlegOrderMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds); factory.setConfiguration(configuration);
var repository = new RelabelSourceRepository(new SqlSessionTemplate(factory.getObject()).getMapper(LastlegOrderMapper.class));
assertThat(repository.dropshipInboundCodes("C1", "W1")).containsExactly("DROP");
assertThat(repository.dropshipInboundCodes("C2", "W1")).containsExactly("FOREIGN");
assertThat(repository.dropshipInboundCodes("C1", "W2")).containsExactly("OTHER-WAREHOUSE");
}
}
@@ -1283,6 +1283,20 @@ class WarehouseTransferServiceTest {
verify(orders, never()).insert(any(LastlegOrder.class));
}
@Test
void staleDropshipSelectionCannotCreateWarehouseTransfer() {
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
var dropship = historicalOrder(); dropship.setOrderType("ONE_CLICK_DROPSHIP");
when(orders.selectList(any())).thenReturn(List.of(dropship));
var error = assertThrows(ResponseStatusException.class,
() -> service.create("TENANT-A", "USER-1", request(false)));
assertEquals("一件代发订单不支持仓储转运", error.getReason());
verify(transfers, never()).insert(any(WarehouseTransferOrder.class));
verifyNoInteractions(inventory);
}
@Test
void missingOrForeignCustomerNameStopsCreationBeforeAnyBusinessWrites() {
for (var customer : List.of(