This commit is contained in:
2026-10-14 18:01:25 +08:00
parent 8bc8518496
commit 471e50ffe1
34 changed files with 1088 additions and 71 deletions
+2
View File
@@ -149,3 +149,5 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
码头车队邮件配置见 [Firstleg 车队邮件](../../firstleg/docs/terminal-drayage-mail.md)。
`migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql` 仅新增废弃照片关联表,按客户、申请和托盘关联文件及提交时间。与 Lastleg、File 和三端照片功能共同发布;不回填历史废弃单,不修改库存。脚本存在不表示目标环境已执行。
货物废弃通用库存与散箱字段迁移:`migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql`。新增库存仓位关联及箱唛快照,须先迁移再部署 Lastleg;不回填历史废弃单。
@@ -0,0 +1,24 @@
-- 货物废弃支持所有入库来源及散箱全量作业;不回填或修改历史单据。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='inventory_position_id'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN inventory_position_id BIGINT NULL COMMENT ''废弃来源库存仓位ID''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_marks_json'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_marks_json TEXT NULL COMMENT ''本次废弃所选真实箱唛快照JSON数组''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='uk_ll_discard_position_sku'),
'ALTER TABLE ll_cargo_discard_item ADD UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY';
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_scan_required'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要逐箱核对本次所选箱子''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组';
ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED';
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='source_type'),
'ALTER TABLE ll_cargo_discard_carton ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+32 -2
View File
@@ -48,6 +48,7 @@ CREATE TABLE ll_cargo_discard_carton (
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
carton_id bigint NOT NULL COMMENT '箱唛记录ID',
carton_mark varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱唛',
source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_cargo_discard_carton (application_id,carton_id),
@@ -60,13 +61,16 @@ CREATE TABLE ll_cargo_discard_item (
application_id bigint NOT NULL COMMENT '申请单ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
order_id bigint DEFAULT NULL COMMENT '一件代发订单ID',
source_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON',
source_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY',
return_carton_id bigint DEFAULT NULL COMMENT '退货库存箱ID',
inbound_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓单号快照',
profile_id bigint DEFAULT NULL COMMENT '托盘档案ID',
sku_inventory_id bigint DEFAULT NULL COMMENT 'SKU库存记录ID',
sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
product_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品名称',
inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID',
carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组',
carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子',
available_packages_snapshot int NOT NULL COMMENT '可用箱数快照',
discard_packages int NOT NULL COMMENT '申请废弃箱数',
completion_photo_file_ids text COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA完工照片文件ID JSON数组',
@@ -77,6 +81,7 @@ CREATE TABLE ll_cargo_discard_item (
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),
UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code),
KEY idx_ll_cargo_discard_item_list (customer_id,application_id),
KEY idx_ll_cargo_discard_item_inbound (customer_id,inbound_code,sku_code)
,KEY idx_ll_cargo_discard_return_carton (customer_id,return_carton_id)
@@ -1072,7 +1077,7 @@ CREATE TABLE ll_inbound_plan_carton (
warehouse_pallet_id bigint DEFAULT NULL COMMENT '海外实物托盘ID',
warehouse_location_id bigint DEFAULT NULL COMMENT '海外库位ID',
pda_task_id bigint DEFAULT NULL COMMENT '首次扫码PDA任务ID',
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、BULK_RECEIVED、AVAILABLE',
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED',
version int NOT NULL DEFAULT '0' COMMENT '乐观锁版本',
bound_at datetime(3) DEFAULT NULL COMMENT '绑定托盘时间',
received_at datetime(3) DEFAULT NULL COMMENT '完成入库时间',
@@ -4321,6 +4326,31 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo (
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 货物废弃支持所有入库来源及散箱全量作业;不回填或修改历史单据。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='inventory_position_id'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN inventory_position_id BIGINT NULL COMMENT ''废弃来源库存仓位ID''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_marks_json'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_marks_json TEXT NULL COMMENT ''本次废弃所选真实箱唛快照JSON数组''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='uk_ll_discard_position_sku'),
'ALTER TABLE ll_cargo_discard_item ADD UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY';
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_scan_required'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要逐箱核对本次所选箱子''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组';
ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED';
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='source_type'),
'ALTER TABLE ll_cargo_discard_carton ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE ll_selfop_owner (
id BIGINT NOT NULL COMMENT '主键',
run_id BIGINT NOT NULL COMMENT '完整资料采集版本',
@@ -1158,6 +1158,7 @@ public class DeliveryTransferService {
required(request == null ? null : request.reason(), "取消原因不能为空");
OutboundOrder observed = outboundOrders.findGlobalByNo(trim(outboundNo));
if (observed == null) throw notFound("Outbound order not found");
if ("DISCARD_OUTBOUND".equals(observed.getOutboundType())) throw conflict("请通过废弃单取消,废弃出库单随申请同步取消");
// Match dispatch picking and relabel confirmation lock order before locking outbound.
DispatchOrder dispatch = observed.getDispatchOrderId() == null ? null
: dispatchOrders.lockById(observed.getCustomerId(), observed.getDispatchOrderId());
@@ -20,7 +20,14 @@ public final class CargoDiscardRequests {
@Size(max = 1000) String remark) {}
public record Item(Long orderId, Long returnCartonId, @NotBlank @Size(max = 128) String skuCode,
@Positive int discardPackages) {}
@Positive int discardPackages, Long inventoryPositionId, @Size(max = 5000) List<@NotNull @Positive Long> cartonIds) {
public Item(Long orderId, Long returnCartonId, String skuCode, int discardPackages, Long inventoryPositionId) {
this(orderId, returnCartonId, skuCode, discardPackages, inventoryPositionId, null);
}
public Item(Long orderId, Long returnCartonId, String skuCode, int discardPackages) {
this(orderId, returnCartonId, skuCode, discardPackages, null, null);
}
}
public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {}
@@ -11,16 +11,37 @@ public final class CargoDiscardResponses {
String inboundCode, String inboundOrderNo,
String overseasWarehouseId, String overseasWarehouseName,
LocalDateTime inboundCreatedAt, long availablePackages,
List<CandidateSku> skus) {}
List<CandidateSku> skus, Long inventoryPositionId, String palletNo, List<Box> cartons, boolean cartonSelectionSupported, String cartonSelectionBlockReason) {
public Candidate(Long orderId, String sourceType, Long returnCartonId, String cartonMark,
String inboundCode, String inboundOrderNo, String overseasWarehouseId,
String overseasWarehouseName, LocalDateTime inboundCreatedAt, long availablePackages,
List<CandidateSku> skus, Long inventoryPositionId, String palletNo) {
this(orderId, sourceType, returnCartonId, cartonMark, inboundCode, inboundOrderNo,
overseasWarehouseId, overseasWarehouseName, inboundCreatedAt, availablePackages, skus,
inventoryPositionId, palletNo, List.of(), false, "当前库存缺少可核对的箱级记录,可按全部库存废弃");
}
public Candidate(Long orderId, String sourceType, Long returnCartonId, String cartonMark,
String inboundCode, String inboundOrderNo, String overseasWarehouseId,
String overseasWarehouseName, LocalDateTime inboundCreatedAt, long availablePackages,
List<CandidateSku> skus) {
this(orderId, sourceType, returnCartonId, cartonMark, inboundCode, inboundOrderNo,
overseasWarehouseId, overseasWarehouseName, inboundCreatedAt, availablePackages, skus, null, null);
}
}
public record Box(Long cartonId, String cartonMark, String palletNo, List<CandidateSku> skus) {}
public record CandidateSku(String skuCode, String productName, long availablePackages) {}
public record Summary(Long id, String customerId, String discardNo, String inboundCodes,
String overseasWarehouseId, String overseasWarehouseName,
int totalPackages, BigDecimal totalFee, String currency, String status,
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt) {}
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo) {}
public record Item(Long id, Long orderId, String inboundCode, String skuCode,
String productName, int availablePackagesSnapshot, int discardPackages) {}
String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks) {
public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages) {
this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, List.of());
}
}
public record History(String fromStatus, String toStatus, String operatorId,
String remark, LocalDateTime createdAt) {}
@@ -15,6 +15,7 @@ public class CargoDiscardCarton {
private Long itemId;
private String customerId;
private Long cartonId;
private String sourceType;
private String cartonMark;
private LocalDateTime createdAt;
}
@@ -16,6 +16,9 @@ public class CargoDiscardItem {
private Long orderId;
private String sourceType;
private Long returnCartonId;
private Long inventoryPositionId;
private String cartonMarksJson;
private Boolean cartonScanRequired;
private String inboundCode;
private Long profileId;
private Long skuInventoryId;
@@ -12,6 +12,66 @@ import java.util.List;
@Mapper
public interface CargoDiscardInventoryMapper {
@Select("SELECT p.* FROM ll_inventory_position p WHERE p.customer_id=#{customerId} AND p.id=#{positionId} FOR UPDATE")
com.baiyue.lastleg.inventory.entity.InventoryPosition lockGenericPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId);
@Select("""
SELECT c.id,c.carton_mark,c.sku_code,'INBOUND' source_type
FROM ll_inbound_plan_carton c
JOIN ll_inventory_balance b ON b.customer_id=c.customer_id AND b.inbound_order_id=c.order_id
JOIN ll_inventory_position p ON p.customer_id=b.customer_id AND p.balance_id=b.id AND p.pallet_id=c.warehouse_pallet_id
WHERE p.customer_id=#{customerId} AND p.id=#{positionId} AND c.status='AVAILABLE' AND c.received_at IS NOT NULL
AND COALESCE(b.destination_warehouse_code,'')=COALESCE(c.destination_warehouse_code,'')
UNION ALL
SELECT c.id,c.carton_mark,i.sku_code,'TRANSFER' source_type
FROM ll_inventory_position p
JOIN ll_inventory_balance b ON b.customer_id=p.customer_id AND b.id=p.balance_id
JOIN ll_warehouse_transfer_order t ON t.customer_id=p.customer_id AND t.inbound_order_id=b.inbound_order_id
AND t.transfer_no=p.batch_no AND t.operating_warehouse_id=p.overseas_warehouse_id
JOIN ll_warehouse_transfer_carton c ON c.customer_id=t.customer_id AND c.transfer_order_id=t.id AND c.status='EXPECTED'
JOIN ll_warehouse_transfer_item i ON i.customer_id=c.customer_id AND i.id=c.transfer_item_id
WHERE p.customer_id=#{customerId} AND p.id=#{positionId}
AND (SELECT COUNT(*) FROM ll_inventory_position current_position
WHERE current_position.customer_id=p.customer_id AND current_position.balance_id=p.balance_id
AND current_position.batch_no=p.batch_no AND current_position.overseas_warehouse_id=p.overseas_warehouse_id
AND current_position.available_packages+current_position.hold_packages+current_position.allocated_packages+current_position.picked_packages>0)=1
ORDER BY id
""")
List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow> genericCartons(@Param("customerId") String customerId, @Param("positionId") Long positionId);
@Update("UPDATE ll_warehouse_transfer_carton SET status=#{toStatus},updated_at=NOW(3) " +
"WHERE customer_id=#{customerId} AND id=#{cartonId} AND status=#{fromStatus}")
int changeTransferCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId,
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus);
@Update("UPDATE ll_inbound_plan_carton SET status=#{toStatus},version=version+1,updated_at=NOW(3) " +
"WHERE customer_id=#{customerId} AND id=#{cartonId} AND status=#{fromStatus}")
int changeGenericCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId,
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus);
@Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} AND c.status='AVAILABLE' ORDER BY c.profile_id,c.id")
List<DropshipCarton> availableOrderCartons(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Select("<script>SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
"AND c.status='AVAILABLE' AND c.id IN <foreach collection='cartonIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"AND EXISTS(SELECT 1 FROM ll_dropship_carton_sku cs WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode}) ORDER BY c.profile_id,c.id FOR UPDATE</script>")
List<DropshipCarton> lockSelected(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("skuCode") String skuCode, @Param("cartonIds") List<Long> cartonIds);
@Select("<script>SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.customer_id=p.customer_id AND b.id=p.balance_id " +
"WHERE p.customer_id=#{customerId} AND p.available_packages&gt;0 AND b.available_packages&gt;0 " +
"AND NOT EXISTS(SELECT 1 FROM ll_warehouse_transfer_inventory_allocation a WHERE a.customer_id=p.customer_id AND a.inventory_position_id=p.id AND a.status='RESERVED') " +
"AND NOT EXISTS(SELECT 1 FROM ll_dropship_order_profile d WHERE d.customer_id=p.customer_id AND d.inventory_position_id=p.id) " +
"<if test='keyword != null and keyword != \"\"'>AND b.inbound_code LIKE CONCAT('%',#{keyword},'%')</if> ORDER BY p.id</script>")
List<com.baiyue.lastleg.inventory.entity.InventoryPosition> genericPositions(@Param("customerId") String customerId, @Param("keyword") String keyword);
@Select("SELECT COUNT(*) FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND inventory_position_id=#{positionId}")
long isDropshipPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId);
@Select("SELECT DISTINCT carton_mark FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND position_id=#{positionId} " +
"AND business_type IN ('STOCK_IN','RETURN_STOCK_IN') AND carton_mark IS NOT NULL AND carton_mark<>'' ORDER BY carton_mark")
List<String> looseCartonMarks(@Param("customerId") String customerId, @Param("positionId") Long positionId);
@Select("<script>SELECT o.id order_id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id," +
"o.overseas_warehouse_name,o.created_at,p.id profile_id,s.id sku_inventory_id,s.sku_code,s.product_name," +
"COUNT(c.id) available_packages FROM ll_order o " +
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.discard.model;
import lombok.Data;
/** A real carton whose current stock position can be established from warehouse facts. */
@Data
public class CargoDiscardStockCartonRow {
private Long id;
private String cartonMark;
private String skuCode;
private String sourceType;
}
@@ -22,7 +22,7 @@ public class PdaCargoDiscardController {
}
@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()));
return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request.palletNo(), request.discardNo()));
}
@PostMapping(value = "/{discardNo}/pallets/{palletNo}/photos/files", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<com.baiyue.lastleg.discard.dto.CargoDiscardResponses.Photo> uploadPhoto(
@@ -10,12 +10,18 @@ import java.util.List;
public final class PdaCargoDiscardDtos {
private PdaCargoDiscardDtos() { }
public record Scan(@NotBlank @Size(max = 128) String palletNo) { }
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 Confirm(@NotEmpty List<@Valid ActualLine> lines,
@NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds) { }
@NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds,
List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons) {
public Confirm(List<ActualLine> lines, List<Long> photoFileIds) { this(lines, photoFileIds, null, false); }
}
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) { }
String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo,
String scanMode, List<String> cartonMarks) { }
}
@@ -25,12 +25,14 @@ import java.util.stream.Collectors;
public class PdaCargoDiscardService {
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardPhotoService photoService;
@Resource private CargoDiscardApplicationMapper applications;
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardOutboundService outboundService;
@Resource private CargoDiscardItemMapper items;
@Resource private CargoDiscardCartonMapper cartons;
@Resource private DropshipOrderProfileMapper profiles;
@Resource private CargoDiscardInventoryAdjustmentService adjustments;
@Resource private CargoDiscardService discardService;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardStockService stockService;
public List<PdaCargoDiscardDtos.PalletTask> tasks(PdaWarehouseIdentity identity) {
return applications.selectList(new LambdaQueryWrapper<CargoDiscardApplication>()
@@ -41,7 +43,14 @@ public class PdaCargoDiscardService {
}
public PdaCargoDiscardDtos.PalletTask resolve(PdaWarehouseIdentity identity, String palletNo) {
return tasks(identity).stream().filter(t -> t.palletNo().equals(palletNo.trim())).findFirst()
return resolve(identity, palletNo, null);
}
public PdaCargoDiscardDtos.PalletTask resolve(PdaWarehouseIdentity identity, String palletNo, String discardNo) {
var matches = tasks(identity).stream().filter(t -> t.palletNo().equals(palletNo.trim()) || t.cartonMarks().contains(palletNo.trim()))
.filter(t -> discardNo == null || discardNo.isBlank() || t.discardNo().equals(discardNo.trim())).toList();
if (matches.size() > 1) throw conflict("该托盘有多个废弃任务,请先选择废弃单");
return matches.stream().findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "该仓库没有此托盘的待废弃任务"));
}
@@ -62,12 +71,19 @@ public class PdaCargoDiscardService {
if (!actuals.keySet().equals(task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).collect(Collectors.toSet()))) {
throw conflict("必须提交该托盘全部 SKU 的实际废弃数量");
}
if (task.skus().stream().anyMatch(line -> !Objects.equals(actuals.get(line.itemId()), line.plannedPackages())))
throw conflict("必须完成本次废弃单所选全部箱子,不能漏出");
if ("CARTON".equals(task.scanMode()) && !Boolean.TRUE.equals(request.confirmAllCartons())) {
var scanned = request.scannedCartonMarks() == null ? List.<String>of() : request.scannedCartonMarks();
if (scanned.size() != task.cartonMarks().size() || !new HashSet<>(scanned).equals(new HashSet<>(task.cartonMarks())))
throw conflict("必须扫描全部箱唛,或选择一键全部废弃");
}
List<CargoDiscardItem> palletItems = new ArrayList<>();
for (PdaCargoDiscardDtos.Sku line : task.skus()) {
CargoDiscardItem item = items.selectById(line.itemId());
if (item == null || !application.getCustomerId().equals(item.getCustomerId())
|| !Objects.equals(application.getId(), item.getApplicationId())
|| !Objects.equals(task.profileId(), isReturnItem(item) ? item.getReturnCartonId() : item.getProfileId()))
|| !physicalCode(application, item).equals(task.palletNo()))
throw conflict("废弃明细不属于当前申请或托盘");
if ("COMPLETED".equals(item.getPdaStatus())
&& !Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId())))
@@ -108,6 +124,7 @@ public class PdaCargoDiscardService {
discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED,
CargoDiscardService.PDA_COMPLETED, identity.operatorKey(), "全部托盘 PDA 废弃作业完成,待仓库确认");
}
outboundService.sync(application);
return palletTasks(application).stream().filter(value -> value.palletNo().equals(palletNo.trim()))
.findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单"));
}
@@ -142,27 +159,48 @@ public class PdaCargoDiscardService {
return application;
}
private static final String LOOSE = "散箱";
private List<PdaCargoDiscardDtos.PalletTask> palletTasks(CargoDiscardApplication application) {
return items.listApplication(application.getCustomerId(), application.getId()).stream()
.collect(Collectors.groupingBy(this::physicalGroupKey, LinkedHashMap::new, Collectors.toList()))
.entrySet().stream().map(entry -> {
CargoDiscardItem first = entry.getValue().get(0);
boolean returnSource = isReturnItem(first);
DropshipOrderProfile profile = returnSource ? null : profiles.selectById(first.getProfileId());
ReturnInventoryCarton returnCarton = returnSource ? returnCartons.selectById(first.getReturnCartonId()) : null;
if (!returnSource && (profile == null || !application.getCustomerId().equals(profile.getCustomerId()))) throw conflict("废弃明细关联托盘不存在");
if (returnSource && (returnCarton == null || !application.getCustomerId().equals(returnCarton.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(), returnSource ? firstNonBlank(returnCarton.getPalletCode(), returnCarton.getCartonMark()) : profile.getPalletNo(),
returnSource ? returnCarton.getId() : profile.getId(), status, skus);
}).toList();
var allItems = items.listApplication(application.getCustomerId(), application.getId());
var grouped = allItems.stream().collect(Collectors.groupingBy(i -> physicalCode(application, i), LinkedHashMap::new, Collectors.toList()));
return grouped.entrySet().stream().map(entry -> {
CargoDiscardItem first = entry.getValue().get(0);
var 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(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING";
var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream().anyMatch(i -> Boolean.TRUE.equals(i.getCartonScanRequired()));
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);
}).toList();
}
private String physicalGroupKey(CargoDiscardItem item) {
return isReturnItem(item) ? "RETURN:" + item.getReturnCartonId() : "DROPSHIP:" + item.getProfileId();
private String physicalCode(CargoDiscardApplication application, CargoDiscardItem item) {
String code;
if ("INVENTORY".equals(item.getSourceType())) {
var position = stockService.position(application.getCustomerId(), item.getInventoryPositionId());
if (!application.getOverseasWarehouseId().equals(position.getOverseasWarehouseId())) throw conflict("废弃库存不属于当前仓库");
code = position.getPalletNo();
} else if (isReturnItem(item)) {
var carton = returnCartons.selectById(item.getReturnCartonId());
if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())) throw conflict("废弃明细关联退货箱不存在");
code = carton.getPalletCode();
} else {
var profile = profiles.selectById(item.getProfileId());
if (profile == null || !application.getCustomerId().equals(profile.getCustomerId())) throw conflict("废弃明细关联托盘不存在");
code = profile.getPalletNo();
}
return code == null || code.isBlank() ? LOOSE : code.trim();
}
private List<String> cartonMarks(CargoDiscardApplication application, CargoDiscardItem item) {
if ("INVENTORY".equals(item.getSourceType())) return stockService.cartonMarks(item);
if (isReturnItem(item)) {
var carton = returnCartons.selectById(item.getReturnCartonId());
return carton.getCartonMark() == null ? List.of() : List.of(carton.getCartonMark());
}
return cartons.listApplication(application.getCustomerId(), application.getId()).stream()
.filter(c -> Objects.equals(c.getItemId(), item.getId())).map(CargoDiscardCarton::getCartonMark).filter(Objects::nonNull).toList();
}
private boolean isReturnItem(CargoDiscardItem item) {
return "RETURN_CARTON".equals(item.getSourceType()) || item.getReturnCartonId() != null;
@@ -34,6 +34,7 @@ import java.util.Map;
@Service
public class CargoDiscardInventoryAdjustmentService {
@Resource private CargoDiscardInventoryMapper inventory;
@Resource private CargoDiscardStockService stockService;
@Resource private DropshipCartonLedgerMapper cartonLedgers;
@Resource private DropshipCartonSkuMapper cartonSkus;
@Resource private DropshipSkuInventoryMapper skuInventories;
@@ -47,6 +48,11 @@ public class CargoDiscardInventoryAdjustmentService {
public AdjustmentResult discardPalletItem(String customerId, CargoDiscardApplication application,
CargoDiscardItem item, List<CargoDiscardCarton> links,
int actualPackages, String operatorId, LocalDateTime occurredAt) {
if (actualPackages != item.getDiscardPackages()) throw conflict("必须完成本次废弃单所选全部箱子,不能漏出");
if ("INVENTORY".equals(item.getSourceType())) {
stockService.discard(customerId, operatorId, application, item, actualPackages);
return new AdjustmentResult(item.getDiscardPackages(), actualPackages, 0);
}
if (isReturnItem(item)) return discardReturnItem(customerId, item, actualPackages);
if (actualPackages < 0 || actualPackages > links.size()) throw conflict("实际废弃数量超出该托盘申请数量");
DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId());
@@ -0,0 +1,123 @@
package com.baiyue.lastleg.discard.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Objects;
/** Outbound projection; inventory writes belong exclusively to the discard workflow. */
@Service
public class CargoDiscardOutboundService {
public static final String TYPE = "DISCARD_OUTBOUND";
@Resource private OutboundOrderMapper outbounds;
@Resource private OutboundPalletItemMapper pallets;
@Resource private CargoDiscardItemMapper items;
@Resource private CargoDiscardCartonMapper cartons;
@Resource private DropshipOrderProfileMapper profiles;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private CargoDiscardStockService stockService;
private String key(CargoDiscardApplication application) { return "CARGO_DISCARD:" + application.getId(); }
public String outboundNo(CargoDiscardApplication application) {
var outbound = outbounds.findByIdempotency(application.getCustomerId(), key(application));
return outbound == null ? null : outbound.getOutboundNo();
}
// The caller holds the application lock and owns the approval transaction.
public void create(CargoDiscardApplication application, String operatorId) {
if (outbounds.findByIdempotency(application.getCustomerId(), key(application)) != null) return;
var lines = items.listApplication(application.getCustomerId(), application.getId());
if (lines.isEmpty()) throw conflict("废弃单没有可出库明细");
var links = cartons.listApplication(application.getCustomerId(), application.getId());
LocalDateTime at = LocalDateTime.now();
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId());
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE);
outbound.setLxCodes(application.getInboundCodes()); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo());
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(application.getTotalPackages());
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);
var snapshots = new java.util.ArrayList<OutboundPalletItem>();
var palletCodes = new java.util.HashSet<String>();
for (var line : lines) {
boolean returned = line.getReturnCartonId() != null || "RETURN_CARTON".equals(line.getSourceType());
String palletNo;
String cartonMarks;
if ("INVENTORY".equals(line.getSourceType())) {
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));
} else if (returned) {
var carton = returnCartons.selectById(line.getReturnCartonId());
if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())
|| !application.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId()))
throw conflict("废弃明细关联退货箱不存在或仓库不匹配");
palletNo = carton.getPalletCode() == null || carton.getPalletCode().isBlank() ? null : carton.getPalletCode();
cartonMarks = carton.getCartonMark();
} else {
var profile = profiles.selectById(line.getProfileId());
if (profile == null || !application.getCustomerId().equals(profile.getCustomerId()))
throw conflict("废弃明细关联托盘不存在");
palletNo = profile.getPalletNo();
cartonMarks = links.stream().filter(c -> Objects.equals(c.getItemId(), line.getId()))
.map(c -> c.getCartonMark()).filter(Objects::nonNull).collect(java.util.stream.Collectors.joining(","));
}
if (palletNo != null && !palletNo.isBlank()) palletCodes.add(palletNo);
OutboundPalletItem snapshot = new OutboundPalletItem();
// Stable one-to-one projection key in a separate table, never used to deduct stock.
snapshot.setId(line.getId()); snapshot.setCustomerId(application.getCustomerId()); snapshot.setOutboundOrderId(outbound.getId());
snapshot.setPalletNo(palletNo); snapshot.setSkuCode(line.getSkuCode()); snapshot.setInboundCode(line.getInboundCode());
snapshot.setOrderRefs(application.getDiscardNo()); snapshot.setCartonMarks(cartonMarks);
snapshot.setPackageCount(line.getDiscardPackages()); snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK");
if (returned) snapshot.setReturnSkuQuantity((long) line.getDiscardPackages());
snapshot.setVolumeWeightKg(BigDecimal.ZERO); snapshot.setVolumeCbm(BigDecimal.ZERO); snapshot.setCreatedAt(at);
snapshots.add(snapshot);
}
outbound.setPalletCount(palletCodes.size()); outbounds.insert(outbound);
snapshots.forEach(pallets::insert);
}
public void sync(CargoDiscardApplication application) {
OutboundOrder outbound = outbounds.lockByIdempotency(application.getCustomerId(), key(application));
if (outbound == null) throw conflict("废弃出库单不存在,请联系平台核对审核结果");
var lines = items.listApplication(application.getCustomerId(), application.getId());
for (var snapshot : pallets.findByOutbound(application.getCustomerId(), outbound.getId())) {
var line = lines.stream().filter(i -> Objects.equals(i.getId(), snapshot.getId())).findFirst()
.orElseThrow(() -> conflict("废弃出库明细关联失效"));
if ("COMPLETED".equals(line.getPdaStatus())) {
snapshot.setPickedPackageCount(Objects.requireNonNullElse(line.getActualDiscardPackages(), 0));
snapshot.setStatus("PICKED");
}
if (CargoDiscardService.CANCELLED.equals(application.getStatus())) snapshot.setStatus("CANCELLED");
pallets.updateById(snapshot);
}
String status = switch (application.getStatus()) {
case CargoDiscardService.PDA_COMPLETED, CargoDiscardService.COMPLETED -> "OUTBOUNDED";
case CargoDiscardService.CANCELLED -> "CANCELLED";
default -> lines.stream().anyMatch(i -> "COMPLETED".equals(i.getPdaStatus())) ? "PICKING" : "WAIT_PICK";
};
outbound.setStatus(status);
if ("OUTBOUNDED".equals(status)) outbound.setPackageCount(application.getTotalPackages());
outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound);
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -61,6 +61,10 @@ public class CargoDiscardService {
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED, CANCELLED, PDA_COMPLETED, COMPLETED);
@Resource private CargoDiscardPhotoService photoService;
@Resource private CargoDiscardOutboundService outboundService;
@Resource private CargoDiscardStockService stockService;
@Resource private com.baiyue.lastleg.dropship.mapper.DropshipCartonSkuMapper cartonSkus;
@Resource private com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper profiles;
@Resource private CargoDiscardApplicationMapper applications;
@Resource private CargoDiscardItemMapper items;
@Resource private CargoDiscardCartonMapper discardCartons;
@@ -85,6 +89,7 @@ public class CargoDiscardService {
List<CargoDiscardResponses.Candidate> result = grouped.values().stream()
.map(value -> value.response(inventory.countAvailableOrder(customerId, value.first.getOrderId())))
.collect(java.util.stream.Collectors.toCollection(java.util.ArrayList::new));
result.addAll(stockService.candidates(customerId, trim(inboundCode)));
String keyword = trim(inboundCode);
if (returnCartons == null || returnCartonItems == null) return result;
for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, null)) {
@@ -100,7 +105,28 @@ public class CargoDiscardService {
carton.getSourceReturnNo(), carton.getSourceReturnNo(), carton.getOverseasWarehouseId(), null,
null, available, skus));
}
return result;
return result.stream().map(candidate -> withBoxes(customerId, candidate)).toList();
}
private CargoDiscardResponses.Candidate withBoxes(String customer, CargoDiscardResponses.Candidate candidate) {
if ("INVENTORY".equals(candidate.sourceType())) return candidate;
List<CargoDiscardResponses.Box> boxes;
if ("RETURN_CARTON".equals(candidate.sourceType())) {
var carton = returnCartons.selectById(candidate.returnCartonId());
boxes = List.of(new CargoDiscardResponses.Box(candidate.returnCartonId(), candidate.cartonMark(), carton == null ? null : carton.getPalletCode(), candidate.skus()));
} else {
boxes = inventory.availableOrderCartons(customer, candidate.orderId()).stream().map(carton -> {
var profile = profiles.selectById(carton.getProfileId());
var skus = cartonSkus.listCarton(customer, carton.getId()).stream().map(sku -> new CargoDiscardResponses.CandidateSku(sku.getSkuCode(), sku.getProductName(), 1)).toList();
if (skus.isEmpty()) skus = List.of(new CargoDiscardResponses.CandidateSku(carton.getSkuCode(), null, 1));
return new CargoDiscardResponses.Box(carton.getId(), carton.getCartonMark(), profile == null ? null : profile.getPalletNo(), skus);
}).toList();
}
boolean supported = !boxes.isEmpty() && boxes.stream().allMatch(box -> box.cartonMark() != null && !box.cartonMark().isBlank());
return new CargoDiscardResponses.Candidate(candidate.orderId(), candidate.sourceType(), candidate.returnCartonId(), candidate.cartonMark(),
candidate.inboundCode(), candidate.inboundOrderNo(), candidate.overseasWarehouseId(), candidate.overseasWarehouseName(), candidate.inboundCreatedAt(),
candidate.availablePackages(), candidate.skus(), candidate.inventoryPositionId(), candidate.palletNo(), boxes, supported,
supported ? null : "当前库存缺少完整箱唛,可按全部库存废弃");
}
@Transactional
@@ -116,6 +142,7 @@ public class CargoDiscardService {
return detail(customerId, replay.getDiscardNo());
}
Set<String> requestedDimensions = new LinkedHashSet<>();
Set<String> requestedBoxes = new LinkedHashSet<>();
Map<Long, LastlegOrder> orderCache = new HashMap<>();
Map<Long, ReturnInventoryCarton> returnCartonCache = new HashMap<>();
String warehouseId = null;
@@ -123,19 +150,29 @@ public class CargoDiscardService {
int total = 0;
for (CargoDiscardRequests.Item input : request.items()) {
boolean returnSource = input.returnCartonId() != null;
if (returnSource == (input.orderId() != null)) {
if (input.cartonIds() != null) {
if (input.cartonIds().isEmpty() || input.cartonIds().size() != input.discardPackages()) throw badRequest("请选择具体废弃箱子,箱数须与选择一致");
String source = input.inventoryPositionId() != null ? "INVENTORY" : "DROPSHIP";
for (Long cartonId : input.cartonIds()) if (!requestedBoxes.add(source + ":" + cartonId)) throw badRequest("同一个箱子不能重复废弃");
}
int sources = (returnSource ? 1 : 0) + (input.orderId() != null ? 1 : 0) + (input.inventoryPositionId() != null ? 1 : 0);
if (sources != 1) {
throw badRequest("Each discard item must identify exactly one inventory source");
}
String dimension = (returnSource ? "RETURN:" + input.returnCartonId() : "ORDER:" + input.orderId())
String dimension = (input.inventoryPositionId() != null ? "INVENTORY:" + input.inventoryPositionId() : returnSource ? "RETURN:" + input.returnCartonId() : "ORDER:" + input.orderId())
+ ":" + input.skuCode().trim();
if (!requestedDimensions.add(dimension)) throw badRequest("Duplicate order/SKU discard item: " + dimension);
String itemWarehouseId;
String itemWarehouseName;
if (returnSource) {
if (input.inventoryPositionId() != null) {
var balance = stockService.validate(customerId, input);
itemWarehouseId = balance.getOverseasWarehouseId();
itemWarehouseName = balance.getOverseasWarehouseName();
} else if (returnSource) {
ReturnInventoryCarton carton = requireReturnCarton(customerId, input.returnCartonId());
ReturnInventoryCartonItem sku = returnCartonItems.lockSku(carton.getId(), input.skuCode().trim());
if (sku == null || value(sku.getQuantity()) < input.discardPackages()) {
throw conflict("Insufficient return inventory for SKU " + input.skuCode().trim());
if (sku == null || value(sku.getQuantity()) != input.discardPackages()) {
throw conflict("目前仅支持全部废弃,退货 SKU 数量不匹配: " + input.skuCode().trim());
}
returnCartonCache.put(carton.getId(), carton);
itemWarehouseId = carton.getOverseasWarehouseId();
@@ -155,13 +192,22 @@ public class CargoDiscardService {
total = Math.addExact(total, input.discardPackages());
}
for (Long cartonId : returnCartonCache.keySet()) {
Set<String> availableSkus = returnCartonItems.lockCarton(cartonId).stream()
.filter(sku -> value(sku.getQuantity()) > 0).map(ReturnInventoryCartonItem::getSkuCode)
.collect(java.util.stream.Collectors.toSet());
Set<String> requestedSkus = request.items().stream().filter(i -> cartonId.equals(i.returnCartonId()))
.map(i -> i.skuCode().trim()).collect(java.util.stream.Collectors.toSet());
if (!availableSkus.equals(requestedSkus)) throw conflict("目前不支持部分废弃,必须选择退货箱内全部 SKU 库存");
}
LocalDateTime now = LocalDateTime.now();
CargoDiscardApplication application = new CargoDiscardApplication();
application.setId(IdWorker.getId()); application.setCustomerId(customerId);
application.setDiscardNo(orderNoGenerator.next("DISCARD", warehouseId, application.getId()));
application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName);
application.setInboundCodes(java.util.stream.Stream.concat(
orderCache.values().stream().map(LastlegOrder::getInboundCode),
java.util.stream.Stream.concat(orderCache.values().stream().map(LastlegOrder::getInboundCode),
request.items().stream().filter(i -> i.inventoryPositionId() != null).map(i -> stockService.validate(customerId, i).getInboundCode())),
returnCartonCache.values().stream().map(ReturnInventoryCarton::getSourceReturnNo))
.distinct().sorted().collect(java.util.stream.Collectors.joining(",")));
application.setTotalPackages(total); application.setFeePerPackage(feePerPackage);
@@ -176,7 +222,9 @@ public class CargoDiscardService {
Set<Long> reservedReturnCartons = new LinkedHashSet<>();
for (CargoDiscardRequests.Item input : request.items()) {
if (input.returnCartonId() != null) {
if (input.inventoryPositionId() != null) {
stockService.reserve(customerId, operatorId, application, input, now);
} else if (input.returnCartonId() != null) {
reserveReturn(customerId, application, returnCartonCache.get(input.returnCartonId()), input, now,
reservedReturnCartons);
} else {
@@ -187,6 +235,16 @@ public class CargoDiscardService {
return detail(customerId, application.getDiscardNo());
}
private List<String> selectedMarks(String customer, CargoDiscardApplication application, CargoDiscardItem line) {
if ("INVENTORY".equals(line.getSourceType())) return stockService.cartonMarks(line);
if (isReturnItem(line)) {
var carton = returnCartons.selectById(line.getReturnCartonId());
return carton == null || carton.getCartonMark() == null ? List.of() : List.of(carton.getCartonMark());
}
return discardCartons.listApplication(customer, application.getId()).stream().filter(link -> link.getItemId().equals(line.getId()))
.map(CargoDiscardCarton::getCartonMark).distinct().sorted().toList();
}
public PageResult<CargoDiscardResponses.Summary> page(String customerId, CargoDiscardRequests.Query query) {
validateQuery(query);
LambdaQueryWrapper<CargoDiscardApplication> wrapper = new LambdaQueryWrapper<CargoDiscardApplication>()
@@ -209,7 +267,7 @@ public class CargoDiscardService {
if (application == null) throw notFound("Cargo discard application not found");
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()))
value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages(), selectedMarks(customerId, application, value)))
.toList();
List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(),
@@ -244,9 +302,10 @@ public class CargoDiscardService {
.collect(java.util.stream.Collectors.groupingBy(CargoDiscardCarton::getItemId));
String historyRemark = request.remark();
if (REJECTED.equals(next)) {
release(customerId, links, applicationItems);
release(customerId, application, links, applicationItems);
}
if (APPROVED.equals(next)) outboundService.create(application, operatorId);
LocalDateTime reviewedAt = now();
application.setStatus(next); application.setReviewedBy(operatorId);
application.setReviewRemark(trim(request.remark())); application.setReviewedAt(reviewedAt);
@@ -287,6 +346,7 @@ public class CargoDiscardService {
LastlegOrderStatusHistory h = new LastlegOrderStatusHistory(); h.setId(IdWorker.getId()); h.setCustomerId(customerId); h.setOrderId(linked.getId());
h.setFromStatus(linkedFrom); h.setToStatus(linkedTo); h.setEventId("CARGO_DISCARD_STATUS:" + application.getId() + ":" + linkedTo); h.setRemark(remark); h.setCreatedAt(at); orderHistories.insert(h);
}
outboundService.sync(application);
history(customerId, application.getId(), from, to, operatorId, remark, at);
}
@@ -304,7 +364,7 @@ public class CargoDiscardService {
throw conflict("客户只能取消待审核的废弃申请");
}
List<CargoDiscardCarton> links = discardCartons.listApplication(customerId, application.getId());
release(customerId, links, items.listApplication(customerId, application.getId()));
release(customerId, application, links, items.listApplication(customerId, application.getId()));
LocalDateTime at = now();
String from = application.getStatus();
application.setStatus(CANCELLED); application.setReviewedBy(platform ? operatorId : application.getReviewedBy());
@@ -318,6 +378,7 @@ public class CargoDiscardService {
h.setFromStatus(from); h.setToStatus(CANCELLED); h.setEventId("CARGO_DISCARD_CANCEL:" + application.getId()); h.setRemark("废弃申请已取消"); h.setCreatedAt(at);
orderHistories.insert(h);
}
if (APPROVED.equals(from)) outboundService.sync(application);
history(customerId, application.getId(), from, CANCELLED, operatorId, "废弃申请已取消", at);
return detail(customerId, discardNo);
}
@@ -325,11 +386,14 @@ public class CargoDiscardService {
private void reserve(String customerId, CargoDiscardApplication application, LastlegOrder order,
CargoDiscardRequests.Item input, LocalDateTime now) {
String skuCode = input.skuCode().trim();
if (inventory.countAvailable(customerId, order.getId(), skuCode) < input.discardPackages()) {
throw conflict("Insufficient available packages for SKU " + skuCode + " on inbound " + order.getInboundCode());
List<DropshipCarton> selected;
if (input.cartonIds() != null) {
selected = inventory.lockSelected(customerId, order.getId(), skuCode, input.cartonIds());
} else {
if (inventory.countAvailable(customerId, order.getId(), skuCode) != input.discardPackages()) throw conflict("部分废弃必须选择具体箱子,不能只填写数量");
selected = inventory.lockAvailable(customerId, order.getId(), skuCode, input.discardPackages());
}
List<DropshipCarton> selected = inventory.lockAvailable(customerId, order.getId(), skuCode, input.discardPackages());
if (selected.size() != input.discardPackages()) throw conflict("Available cargo changed; retry the discard request");
if (selected.size() != input.discardPackages()) throw conflict("箱子已被占用、SKU 不匹配或不属于当前库存,请刷新后重选");
Map<Long, List<DropshipCarton>> byProfile = selected.stream()
.collect(java.util.stream.Collectors.groupingBy(DropshipCarton::getProfileId, LinkedHashMap::new,
java.util.stream.Collectors.toList()));
@@ -343,6 +407,7 @@ public class CargoDiscardService {
item.setSkuInventoryId(sku.getId()); item.setSkuCode(skuCode); item.setProductName(sku.getProductName());
item.setAvailablePackagesSnapshot(Math.toIntExact(inventory.countAvailableProfile(
customerId, order.getId(), entry.getKey(), skuCode)));
item.setCartonScanRequired(input.cartonIds() != null && entry.getValue().size() < inventory.availableOrderCartons(customerId, order.getId()).stream().filter(c -> c.getProfileId().equals(entry.getKey())).count());
item.setDiscardPackages(entry.getValue().size()); item.setCreatedAt(now); items.insert(item);
for (DropshipCarton carton : entry.getValue()) {
if (inventory.changeCartonStatus(customerId, carton.getId(), "AVAILABLE", "DISCARD_PENDING") != 1) {
@@ -356,12 +421,15 @@ public class CargoDiscardService {
}
}
private void release(String customerId, List<CargoDiscardCarton> links, List<CargoDiscardItem> applicationItems) {
private void release(String customerId, CargoDiscardApplication application, List<CargoDiscardCarton> links, List<CargoDiscardItem> applicationItems) {
Set<Long> genericItems = applicationItems.stream().filter(i -> "INVENTORY".equals(i.getSourceType())).map(CargoDiscardItem::getId).collect(java.util.stream.Collectors.toSet());
for (CargoDiscardCarton link : links) {
if (genericItems.contains(link.getItemId())) continue;
if (inventory.changeCartonStatus(customerId, link.getCartonId(), "DISCARD_PENDING", "AVAILABLE") != 1) {
throw conflict("Reserved cargo status changed before rejection");
}
}
applicationItems.stream().filter(i -> "INVENTORY".equals(i.getSourceType())).forEach(i -> stockService.release(customerId, application, i));
applicationItems.stream().filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId)
.filter(java.util.Objects::nonNull).distinct().forEach(cartonId -> {
if (returnCartons.releaseDiscard(cartonId, customerId) != 1) {
@@ -373,7 +441,7 @@ public class CargoDiscardService {
private void reserveReturn(String customerId, CargoDiscardApplication application, ReturnInventoryCarton carton,
CargoDiscardRequests.Item input, LocalDateTime now, Set<Long> reservedCartons) {
ReturnInventoryCartonItem sku = returnCartonItems.lockSku(carton.getId(), input.skuCode().trim());
if (sku == null || value(sku.getQuantity()) < input.discardPackages()) {
if (sku == null || value(sku.getQuantity()) != input.discardPackages()) {
throw conflict("Available return inventory changed; retry the discard request");
}
if (reservedCartons.add(carton.getId()) && returnCartons.markDiscardPending(carton.getId(), customerId) != 1) {
@@ -413,7 +481,7 @@ public class CargoDiscardService {
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
value.getOverseasWarehouseId(), value.getOverseasWarehouseName(), value.getTotalPackages(),
value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getReviewedAt());
value.getReviewedAt(), outboundService.outboundNo(value));
}
private void history(String customerId, Long applicationId, String from, String to, String operatorId,
@@ -0,0 +1,142 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.discard.dto.*;
import com.baiyue.lastleg.discard.entity.*;
import com.baiyue.lastleg.discard.mapper.*;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.*;
import com.baiyue.lastleg.inventory.mapper.*;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import java.time.LocalDateTime;
/** Stock eligibility is based on physical available positions, independent of the source document type. */
@Service
public class CargoDiscardStockService {
@Resource private CargoDiscardInventoryMapper stock;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@Resource private InventoryCommandService commands;
@Resource private CargoDiscardItemMapper items;
@Resource private CargoDiscardCartonMapper links;
@Resource private ObjectMapper objectMapper;
public List<CargoDiscardResponses.Candidate> candidates(String customer, String keyword) {
return stock.genericPositions(customer, keyword).stream().map(position -> {
var balance = balances.selectById(position.getBalanceId());
if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在");
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
var cartons = stock.genericCartons(customer, position.getId());
boolean complete = cartons.size() == available && cartons.stream().allMatch(c -> c.getCartonMark() != null && !c.getCartonMark().isBlank())
&& cartons.stream().map(c -> c.getCartonMark()).distinct().count() == cartons.size();
var boxes = complete ? cartons.stream().map(c -> new CargoDiscardResponses.Box(c.getId(), c.getCartonMark(), position.getPalletNo(),
List.of(new CargoDiscardResponses.CandidateSku(c.getSkuCode(), balance.getProductNameZh(), 1)))).toList() : List.<CargoDiscardResponses.Box>of();
return new CargoDiscardResponses.Candidate(null, "INVENTORY", null, null,
balance.getInboundCode(), balance.getInboundOrderNo(), balance.getOverseasWarehouseId(),
balance.getOverseasWarehouseName(), position.getCreatedAt(), available,
List.of(new CargoDiscardResponses.CandidateSku(sku(balance), balance.getProductNameZh(), available)),
position.getId(), position.getPalletNo(), boxes, complete,
complete ? null : "当前库存缺少完整箱唛与托盘对应记录,可按全部库存废弃");
}).filter(c -> c.availablePackages() > 0).toList();
}
public InventoryBalance validate(String customer, CargoDiscardRequests.Item request) {
var position = position(customer, request.inventoryPositionId());
var balance = balances.selectById(position.getBalanceId());
if (balance == null || !customer.equals(balance.getCustomerId())
|| !Objects.equals(position.getOverseasWarehouseId(), balance.getOverseasWarehouseId()))
throw conflict("库存不属于当前客户或仓库");
if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存");
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
if (available <= 0 || request.discardPackages() <= 0 || request.discardPackages() > available) throw conflict("所选废弃库存数量不足");
if (request.cartonIds() != null) {
if (request.cartonIds().size() != request.discardPackages() || new HashSet<>(request.cartonIds()).size() != request.cartonIds().size())
throw conflict("废弃箱子数量与所选箱子不一致或重复");
var cartons = stock.genericCartons(customer, position.getId());
var ids = cartons.stream().map(c -> c.getId()).collect(java.util.stream.Collectors.toSet());
if (cartons.size() != available || cartons.stream().anyMatch(c -> c.getCartonMark() == null || c.getCartonMark().isBlank()) || cartons.stream().map(c -> c.getCartonMark()).distinct().count() != cartons.size() || !ids.containsAll(request.cartonIds())) throw conflict("箱唛库存已变化或不属于所选托盘,请刷新后重选");
} else if (request.discardPackages() != available) throw conflict("部分废弃必须选择具体箱子,不能只填写数量");
if (stock.isDropshipPosition(customer, position.getId()) > 0) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留");
return balance;
}
public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) {
if (stock.lockGenericPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在");
var balance = validate(customer, request);
var position = position(customer, request.inventoryPositionId());
var line = new CargoDiscardItem(); line.setId(IdWorker.getId()); line.setApplicationId(application.getId());
line.setCustomerId(customer); line.setSourceType("INVENTORY"); line.setInventoryPositionId(position.getId());
line.setInboundCode(balance.getInboundCode()); line.setSkuCode(sku(balance)); line.setProductName(balance.getProductNameZh());
line.setAvailablePackagesSnapshot(Math.toIntExact(Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())))); line.setDiscardPackages(request.discardPackages()); line.setCreatedAt(at);
var availableCartons = stock.genericCartons(customer, position.getId());
var selected = request.cartonIds() == null
? (availableCartons.size() == request.discardPackages() ? availableCartons : List.<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow>of())
: availableCartons.stream().filter(c -> request.cartonIds().contains(c.getId())).toList();
if (!selected.isEmpty()) {
for (var carton : selected) {
if (changeBox(customer, carton.getId(), carton.getSourceType(), "TRANSFER".equals(carton.getSourceType()) ? "EXPECTED" : "AVAILABLE", "DISCARD_PENDING") != 1) throw conflict("箱子已被其他作业占用,请刷新后重选");
var link = new CargoDiscardCarton(); link.setId(IdWorker.getId()); link.setCustomerId(customer); link.setApplicationId(application.getId());
link.setItemId(line.getId()); link.setSourceType(carton.getSourceType()); link.setCartonId(carton.getId()); link.setCartonMark(carton.getCartonMark()); link.setCreatedAt(at); links.insert(link);
}
snapshotMarks(line, selected.stream().map(c -> c.getCartonMark()).toList());
line.setCartonScanRequired(request.discardPackages() < value(position.getAvailablePackages()) + value(position.getHoldPackages()) + value(position.getAllocatedPackages()) + value(position.getPickedPackages()));
} else if (position.getPalletNo() == null || position.getPalletNo().isBlank()) {
var marks = stock.looseCartonMarks(customer, position.getId());
if (marks.size() != request.discardPackages() || marks.stream().distinct().count() != marks.size()) throw conflict("散箱库存缺少完整箱唛,请先核对实物箱级入库记录");
snapshotMarks(line, marks);
}
commands.hold(customer, operator, move(application, line, request.discardPackages(), "HOLD"));
items.insert(line);
}
public void release(String customer, CargoDiscardApplication application, CargoDiscardItem line) {
changeSelectedCartons(customer, application, line, "AVAILABLE");
commands.release(customer, application.getCreatedBy(), move(application, line, line.getDiscardPackages(), "RELEASE"));
}
public void discard(String customer, String operator, CargoDiscardApplication application, CargoDiscardItem line, int actual) {
if (actual != line.getDiscardPackages()) throw conflict("必须完成本次申请所选全部箱子,不能漏出");
changeSelectedCartons(customer, application, line, "DISCARDED");
commands.discardHeld(customer, operator, move(application, line, actual, "DISCARD"));
}
private int changeBox(String customer, Long cartonId, String source, String from, String to) {
if ("TRANSFER".equals(source)) return stock.changeTransferCartonStatus(customer, cartonId, from, to);
if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确");
return stock.changeGenericCartonStatus(customer, cartonId, from, to);
}
private void snapshotMarks(CargoDiscardItem line, List<String> marks) {
try { line.setCartonMarksJson(objectMapper.writeValueAsString(marks)); }
catch (Exception e) { throw new IllegalStateException("Cannot snapshot carton marks", e); }
}
private void changeSelectedCartons(String customer, CargoDiscardApplication application, CargoDiscardItem line, String status) {
for (var link : links.listApplication(customer, application.getId())) {
if (Objects.equals(link.getItemId(), line.getId()) && changeBox(customer, link.getCartonId(), link.getSourceType(), "DISCARD_PENDING", "TRANSFER".equals(link.getSourceType()) && "AVAILABLE".equals(status) ? "EXPECTED" : status) != 1)
throw conflict("预留箱子的库存状态发生变化");
}
}
private InventoryCommandRequests.Move move(CargoDiscardApplication application, CargoDiscardItem line, int count, String action) {
String cycle = "CARGO_DISCARD:" + application.getId() + ":" + line.getId();
return new InventoryCommandRequests.Move(line.getInventoryPositionId(), count, application.getDiscardNo(),
null, cycle, cycle + ":" + action, "货物废弃 " + action);
}
public InventoryPosition position(String customer, Long id) {
var position = positions.findTenantPosition(customer, id);
if (position == null) throw conflict("废弃库存仓位不存在或不属于当前客户");
return position;
}
public List<String> cartonMarks(CargoDiscardItem line) {
if (line.getCartonMarksJson() == null) return List.of();
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); }
}
private static String sku(InventoryBalance balance) { return balance.getSkuCode() == null || balance.getSkuCode().isBlank() ? "UNSPECIFIED" : balance.getSkuCode(); }
private static long value(Long value) { return value == null ? 0 : value; }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -242,7 +242,7 @@ public interface DispatchCandidateMapper {
"LEFT JOIN ll_warehouse_transfer_order wto ON wto.relabel_no=a.relabel_no AND wto.customer_id=a.customer_id " +
"LEFT JOIN ll_warehouse_transfer_pallet wtp ON wtp.transfer_order_id=wto.id AND wtp.customer_id=a.customer_id " +
"AND wtp.warehouse_pallet_id=pos.pallet_id " +
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
"WHERE a.delivery_method!='EXPRESS' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
@@ -269,7 +269,7 @@ public interface DispatchCandidateMapper {
"FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " +
"JOIN ll_return_inventory_carton c ON c.id=ri.return_carton_id AND c.customer_id=a.customer_id " +
"AND c.relabel_application_id=a.id AND c.overseas_warehouse_id=a.overseas_warehouse_id " +
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
"WHERE a.delivery_method!='EXPRESS' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
"AND c.status='RELABEL_PENDING' AND c.pallet_code IS NOT NULL " +
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
@@ -114,8 +114,8 @@ public class DispatchService {
if (!RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
}
if (!"TRUCK".equals(application.getDeliveryMethod())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "该换标单不是卡车派送,无需进行排车");
if ("EXPRESS".equals(application.getDeliveryMethod())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "该换标单为快递派送,无需进行排车");
}
return application.getCustomerId();
}
@@ -592,7 +592,7 @@ public class DispatchService {
}
relabelBindingGuard.requireBound(application);
relabelMeasurements.requireMeasured(application);
if (!"TRUCK".equals(application.getDeliveryMethod()) || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus()))
if ("EXPRESS".equals(application.getDeliveryMethod()) || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标单不是待排车状态");
List<DispatchPalletCandidate> selected = java.util.stream.Stream.concat(
measuredRelabelCandidates(candidates.relabelAvailable(customerId, null, request.relabelNo())).stream(),
@@ -294,6 +294,40 @@ public class InventoryCommandService {
}
}
/** Internal discard workflow: remove only stock held by the same discard item. */
@Transactional
public InventoryCommandResponse discardHeld(String customerId, String operatorId, InventoryCommandRequests.Move request) {
if (request.packages() <= 0) throw badRequest("废弃数量必须为正数");
return execute(customerId, operatorId, "DISCARD", request.idempotencyKey(), request, operation -> {
Locked locked = locked(customerId, request.positionId());
InventoryPosition position = locked.position();
InventoryBalance balance = locked.balance();
requireReservation(customerId, request.positionId(), null, request.packages(), "DISCARD");
long owned = ledgers.orderHoldCycle(customerId, balance.getInboundOrderId(),
required(request.sourceEventId(), "废弃库存预留标识不能为空")).stream()
.filter(l -> Objects.equals(l.getPositionId(), position.getId())
&& Objects.equals(l.getBusinessOrderNo(), request.businessOrderNo()))
.mapToLong(l -> value(l.getDeltaPackages())).sum();
requireAtLeast(owned, request.packages(), "discard reservation");
long before = value(position.getHoldPackages());
requireAtLeast(before, request.packages(), "held");
requireAtLeast(value(balance.getHoldPackages()), request.packages(), "balance held");
position.setHoldPackages(before - request.packages());
position.setStatus(positionPackages(position) == 0 ? "EMPTY" : "OCCUPIED");
position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(LocalDateTime.now());
positions.updateById(position);
balance.setHoldPackages(value(balance.getHoldPackages()) - request.packages()); touch(balance);
balances.updateById(balance);
boolean empty = position.getPalletId() != null && commands.palletPackages(position.getPalletId()) == 0;
if (empty) warehouseGateway.scrapPallet(customerId, position.getPalletId());
ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), "DISCARD",
"HOLD", "DISCARDED", before, -request.packages(), before - request.packages(),
request.businessOrderNo(), balance.getInboundOrderNo(), request.outboundOrderNo(), request.sourceEventId(),
request.idempotencyKey(), request.remark());
return response(operation, balance, position, empty);
});
}
private InventoryCommandResponse ship(String customerId, String operatorId,
InventoryCommandRequests.Move request, String skuSnapshot) {
return ship(customerId, operatorId, request, skuSnapshot, null);
@@ -80,6 +80,7 @@ public class PdaRelabelService {
return result;
}
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard bindingGuard;
@Resource private com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService palletMeasurements;
public static final String AVAILABLE = "AVAILABLE";
public static final String PROCESSING = "PROCESSING";
public static final String WAIT_REVIEW = "WAIT_REVIEW";
@@ -454,6 +455,7 @@ public class PdaRelabelService {
PdaRelabelTask task = requireOwnedTask(application, operatorKey, true);
requireVersion(task, request.expectedVersion());
if (!request.manualCompletion()) bindingGuard.requireBound(application);
palletMeasurements.requireMeasured(application);
List<RelabelItem> detailItems = itemList(application);
List<PdaRelabelUnitFact> facts = factList(task);
boolean incomplete = detailItems.stream().anyMatch(item -> processedQuantity(facts, item.getId()) < value(item.getNotifiedPackageCount()));
@@ -814,7 +816,7 @@ public class PdaRelabelService {
task == null ? null : task.getCompletionRemark(), task == null ? null : task.getReviewDecision(),
task == null ? null : task.getReviewRemark(), task == null ? null : task.getClaimedAt(),
task == null ? null : task.getSubmittedAt(), task == null ? null : task.getReviewedAt(),
itemResponses, factResponses, historyResponses, application.getDeliveryMethod(), "TRUCK".equals(application.getDeliveryMethod()));
itemResponses, factResponses, historyResponses, application.getDeliveryMethod(), !"EXPRESS".equals(application.getDeliveryMethod()));
}
private String effectiveTaskStatus(RelabelApplication application, PdaRelabelTask task) {
@@ -78,7 +78,7 @@ public class RelabelApplicationService implements WalletResultHandler {
public static final String READY_FOR_OUTBOUND = "READY_FOR_OUTBOUND";
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, PENDING_PROCESSING, "PROCESSING", CUSTOMER_CONFIRM_PENDING,
READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED");
private static final Set<String> DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "TRUCK_PICKUP", "EXPRESS_PICKUP", "PICKUP");
private static final Set<String> DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "AMAZON_PICKUP", "CUSTOMER_PICKUP", "PICKUP");
@Resource private RelabelApplicationMapper applications;
@Resource private RelabelItemMapper items;
@@ -373,7 +373,7 @@ public class RelabelApplicationService implements WalletResultHandler {
if (!requiresOutbound && StringUtils.hasText(deliveryMethod)) {
throw badRequest("SKU-only relabel does not create an outbound fulfillment");
}
if (Boolean.TRUE.equals(request.printShippingLabel()) && !Set.of("EXPRESS", "EXPRESS_PICKUP").contains(deliveryMethod)) {
if (Boolean.TRUE.equals(request.printShippingLabel()) && !Set.of("EXPRESS").contains(deliveryMethod)) {
throw badRequest("Shipping-label printing is only available for express delivery");
}
String requestHash = hash(request);
@@ -951,9 +951,9 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setCustomerConfirmationKey(key);
application.setCustomerConfirmedAt(now);
application.setFulfillmentStatus(requiresOutbound
? ("TRUCK".equals(application.getDeliveryMethod()) ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION")
? ("EXPRESS".equals(application.getDeliveryMethod()) ? "WAIT_OUTBOUND_CREATION" : "WAIT_PRE_DISPATCH")
: "NOT_REQUIRED");
if (requiresOutbound && !existingStockTransfer && !"TRUCK".equals(application.getDeliveryMethod())) {
if (requiresOutbound && !existingStockTransfer && "EXPRESS".equals(application.getDeliveryMethod())) {
createDirectOutbound(application, operatorId, now);
application.setFulfillmentStatus("OUTBOUND_CREATED");
}
@@ -993,7 +993,7 @@ public class RelabelApplicationService implements WalletResultHandler {
carton.setRelabelStatus("COMPLETED"); carton.setRelabelCompletedAt(now);
// 换标已完成后释放“当前申请占用”关系,但保留 COMPLETED 作为历史状态。
// 否则退货箱虽显示 AVAILABLE,下一次贴标申请仍会被旧 relabel_application_id 拦截。
boolean awaitingTruck = requiresOutbound && "TRUCK".equals(application.getDeliveryMethod());
boolean awaitingTruck = requiresOutbound && !"EXPRESS".equals(application.getDeliveryMethod());
if (!StringUtils.hasText(application.getOutboundOrderNo()) && !awaitingTruck) {
carton.setRelabelApplicationId(null); carton.setRelabelNo(null);
}
@@ -58,7 +58,7 @@ public class RelabelBindingGuard {
returnLines.lockCarton(item.getReturnCartonId()).forEach(line -> actual.merge(line.getSkuCode().toUpperCase(Locale.ROOT), line.getQuantity(), Long::sum));
if (!expected.equals(actual)) throw conflict("包裹/箱混贴必须包含实物全部 SKU 和件数;部分数量请先隔离为独立新箱");
}
if (!"TRUCK".equals(app.getDeliveryMethod())) continue;
if ("EXPRESS".equals(app.getDeliveryMethod())) continue;
if (carton.getPalletCode() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode()))
throw conflict("退货箱尚未完成排车托盘绑定");
continue;
@@ -76,7 +76,7 @@ public class RelabelBindingGuard {
var actual = new HashMap<String, Long>();
stock.forEach(i -> actual.merge(i.getSkuCode().toUpperCase(Locale.ROOT), i.getQuantity(), Long::sum));
if (!selected.equals(actual)) throw conflict("部分换标货物必须先合箱生成独立新箱,再提交完工");
if (!"TRUCK".equals(app.getDeliveryMethod())) continue;
if ("EXPRESS".equals(app.getDeliveryMethod())) continue;
var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId());
if (carton == null || carton.getPalletCode() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode())
|| !"PROCESSING".equals(carton.getRelabelStatus()))
@@ -683,7 +683,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton (
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID',
line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID',
warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID',
status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、BULK_RECEIVED、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
status VARCHAR(32) NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark),
@@ -2621,15 +2621,19 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_application (
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 NULL COMMENT '一件代发订单ID', source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON',
order_id BIGINT NULL COMMENT '一件代发订单ID', source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY',
return_carton_id BIGINT NULL COMMENT '退货库存箱ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NULL COMMENT '托盘档案ID',
sku_inventory_id BIGINT 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 '申请废弃箱数',
inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID',
carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组',
carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子',
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完成时间',
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),
UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code),
KEY idx_ll_cargo_discard_item_list(customer_id,application_id),
KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code)
) ENGINE=InnoDB COMMENT='货物废弃SKU明细';
@@ -2637,6 +2641,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton (
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛',
source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id),
KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id),
@@ -9,6 +9,23 @@ info:
photo sets are immutable; identical replay does not deduct stock or create evidence again.
Platform and merchant details expose only submitted photos, including photos of completed
pallets while other pallets are still pending. Historic applications are not backfilled.
Approval atomically creates one DISCARD_OUTBOUND document and planned SKU/pallet snapshots.
Platform/merchant summary and PDA pallet task include nullable outboundNo. PDA completion
synchronizes actual quantities and OUTBOUNDED status without a second inventory deduction.
Cancellation must use the discard application workflow. PDA resolve optionally accepts
discardNo together with palletNo; ambiguous scans require selection of a discard application.
Candidates include INVENTORY positions from every document type with available warehouse stock,
plus DROPSHIP and RETURN_CARTON sources. Create items identify exactly one of orderId,
returnCartonId or inventoryPositionId and carry selected cartonIds for partial whole-carton disposal; quantities must match those IDs.
RETURN_CARTON selections consume every SKU in the selected physical carton.
Candidates return cartons, cartonSelectionSupported and cartonSelectionBlockReason.
Stock without complete carton/pallet ownership remains available for full disposal only.
PDA tasks return scanMode PALLET or CARTON and the real cartonMarks for loose stock.
Bound pallets require their pallet number; loose stock requires every distinct carton mark scanned,
or confirmAllCartons=true. The loose group uses 散箱 as its API path key, never as a scan code.
The application may select a subset of available cartons. PDA must finish every selected carton;
short actual quantities are rejected. At least one photo remains required.
Partial bound-pallet tasks use CARTON scanMode with the real palletNo and selected cartonMarks.
security:
- bearerAuth: []
paths:
@@ -68,7 +85,16 @@ paths:
required: [itemId, actualPackages]
properties:
itemId: { type: string, pattern: '^[1-9][0-9]*$' }
actualPackages: { type: integer, minimum: 0 }
actualPackages: { type: integer, minimum: 1, description: Must equal plannedPackages; must finish the application selection }
scannedCartonMarks:
type: array
uniqueItems: true
items: { type: string }
description: All task cartonMarks must be scanned unless confirmAllCartons is true.
confirmAllCartons:
type: boolean
default: false
description: Explicitly confirm all loose cartons without individual scans; photos remain mandatory.
photoFileIds:
type: array
minItems: 1
@@ -3208,7 +3208,7 @@ components:
chargeAmount: { type: number, nullable: true, minimum: 0.01, description: 本次贴标费用(CNY);未传时不冻结或结算资金 }
remark: { type: string, nullable: true, maxLength: 1000 }
idempotencyKey: { type: string, maxLength: 255 }
deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, TRUCK_PICKUP, EXPRESS_PICKUP, PICKUP], description: 只换箱唛或SKU与箱唛同时换时必填;只换SKU时不传 }
deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, AMAZON_PICKUP, CUSTOMER_PICKUP, PICKUP], description: 只换箱唛或SKU与箱唛同时换时必填;只换SKU时不传 }
printShippingLabel: { type: boolean, default: false, description: 仅快递派送可选,客户确认前必须已上传面单 }
items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/RelabelItem' } }
RelabelItem:
@@ -415,9 +415,10 @@ paths:
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete:
post:
summary: 提交现场任务到复核池并直接等待客户确认
description: 仓储转运换标须先确认所有涉及托盘的有效实测尺寸和重量,含拆托后原托盘剩余货物;手动完成也不能跳过实测校验。缺失或库存版本变化导致实测失效时,保持处理中并提示补测。
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelComplete' } } } }
responses: { '200': { description: WAIT_REVIEW任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 箱数未核平、照片或版本冲突 } }
responses: { '200': { description: WAIT_REVIEW任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 托盘实测缺失或失效、箱数未核平、照片或版本冲突 } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review:
post:
summary: 已停用;客户确认完成复核,退回重贴由平台执行
@@ -42,6 +42,7 @@ class PdaCargoDiscardServiceTest {
@BeforeEach void setup() {
service = new PdaCargoDiscardService();
ReflectionTestUtils.setField(service, "outboundService", org.mockito.Mockito.mock(com.baiyue.lastleg.discard.service.CargoDiscardOutboundService.class));
ReflectionTestUtils.setField(service, "photoService", photoService);
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "items", items);
@@ -122,8 +123,9 @@ class PdaCargoDiscardServiceTest {
@Test void loadedForeignCustomerItemCannotRecover() { finished("COMPLETED"); item.setCustomerId("OTHER"); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); }
@Test void loadedDifferentApplicationItemCannotRecover() { finished("COMPLETED"); item.setApplicationId(2L); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); }
@ParameterizedTest @ValueSource(ints = {3, 1, 0})
void threeLocalOrdersUploadConfirmAndReplayWithFullPartialAndZeroActualDisposal(int actual) {
@Test
void fullDisposalUploadsConfirmsAndReplaysWithoutDoubleDeduction() {
int actual = 3;
var realPhotos = new com.baiyue.lastleg.discard.service.CargoDiscardPhotoService();
var photoMapper = mock(com.baiyue.lastleg.discard.mapper.CargoDiscardPhotoMapper.class);
var photoFiles = mock(com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.class);
@@ -151,4 +153,56 @@ class PdaCargoDiscardServiceTest {
verify(photoMapper, times(1)).updateById(any(com.baiyue.lastleg.discard.entity.CargoDiscardPhoto.class));
assertThrows(ResponseStatusException.class, () -> service.uploadPhoto(identity, "CD-1", "PALLET-1", 9L, new org.springframework.mock.web.MockMultipartFile("file", new byte[]{1})));
}
@ParameterizedTest @ValueSource(ints = {0, 1, 2})
void partialAndZeroDisposalDoNotWriteInventoryOrPhotos(int actual) {
item.setDiscardPackages(3);
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(actual)));
noWrites(); verifyNoInteractions(photoService);
}
private void looseTask() {
var stockService = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class);
ReflectionTestUtils.setField(service, "stockService", 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");
when(stockService.position("CUSTOMER", 42L)).thenReturn(position);
when(stockService.cartonMarks(item)).thenReturn(List.of("BOX-1", "BOX-2"));
}
private PdaCargoDiscardDtos.Confirm looseRequest(List<String> marks, boolean bulk) {
return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 2)), List.of(101L), marks, bulk);
}
@Test void looseTaskExposesAllRealMarksAndRejectsMissingDuplicateAndUnknownScans() {
looseTask(); when(applications.selectList(any())).thenReturn(List.of(application));
var task = service.resolve(identity, "BOX-1", "CD-1");
assertEquals("CARTON", task.scanMode()); assertEquals(List.of("BOX-1", "BOX-2"), task.cartonMarks());
for (var marks : List.of(List.of("BOX-1"), List.of("BOX-1", "BOX-1"), List.of("BOX-1", "OTHER")))
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "散箱", looseRequest(marks, false)));
noWrites(); verifyNoInteractions(photoService);
}
@ParameterizedTest @ValueSource(booleans = {false, true})
void looseTaskCompletesAfterAllScansOrExplicitBulkAndReplays(boolean bulk) {
looseTask(); var request = looseRequest(bulk ? List.of() : List.of("BOX-2", "BOX-1"), bulk);
assertEquals("COMPLETED", service.confirm(identity, "CD-1", "散箱", request).status());
assertEquals(CargoDiscardService.PDA_COMPLETED, application.getStatus());
service.confirm(identity, "CD-1", "散箱", request);
verify(adjustments, times(1)).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(2), eq("OP"), any());
verify(photoService).confirm(same(application), eq("散箱"), eq(List.of(101L)), eq(false));
}
@Test void partialBoundPalletOnlyExposesSelectedBoxesAndRequiresTheirScan() {
looseTask(); item.setCartonScanRequired(true);
var stockService = (com.baiyue.lastleg.discard.service.CargoDiscardStockService) ReflectionTestUtils.getField(service, "stockService");
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition(); position.setOverseasWarehouseId("99"); position.setPalletNo("P-42");
when(stockService.position("CUSTOMER", 42L)).thenReturn(position);
when(applications.selectList(any())).thenReturn(List.of(application));
var task = service.resolve(identity, "P-42", "CD-1");
assertEquals("CARTON", task.scanMode()); assertEquals("P-42", task.palletNo());
assertEquals(List.of("BOX-1", "BOX-2"), task.cartonMarks());
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "P-42", looseRequest(List.of("BOX-1"), false)));
verifyNoInteractions(adjustments);
service.confirm(identity, "CD-1", "P-42", looseRequest(List.of("BOX-1", "BOX-2"), false));
service.confirm(identity, "CD-1", "P-42", looseRequest(List.of("BOX-1", "BOX-2"), false));
verify(adjustments, times(1)).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(2), eq("OP"), any());
}
}
@@ -0,0 +1,98 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.delivery.entity.*;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.discard.entity.*;
import com.baiyue.lastleg.discard.mapper.*;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class CargoDiscardOutboundServiceTest {
private final OutboundOrderMapper outbounds = mock(OutboundOrderMapper.class);
private final OutboundPalletItemMapper pallets = mock(OutboundPalletItemMapper.class);
private final CargoDiscardItemMapper items = mock(CargoDiscardItemMapper.class);
private final CargoDiscardCartonMapper cartons = mock(CargoDiscardCartonMapper.class);
private final DropshipOrderProfileMapper profiles = mock(DropshipOrderProfileMapper.class);
private final ReturnInventoryCartonMapper returnCartons = mock(ReturnInventoryCartonMapper.class);
private CargoDiscardOutboundService service;
private CargoDiscardApplication application;
private CargoDiscardItem first, second;
private OutboundOrder document;
private final List<OutboundPalletItem> snapshots = new ArrayList<>();
@BeforeEach void setup() {
service = new CargoDiscardOutboundService();
ReflectionTestUtils.setField(service, "outbounds", outbounds);
ReflectionTestUtils.setField(service, "pallets", pallets);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "cartons", cartons);
ReflectionTestUtils.setField(service, "profiles", profiles);
ReflectionTestUtils.setField(service, "returnCartons", returnCartons);
ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator());
application = new CargoDiscardApplication(); application.setId(1L); application.setCustomerId("C1");
application.setDiscardNo("CD-TEST"); application.setOverseasWarehouseId("99");
application.setTotalPackages(5); application.setStatus("APPROVED");
first = line(11L, 21L, 3); second = line(12L, 22L, 2);
when(items.listApplication("C1", 1L)).thenReturn(List.of(first, second));
when(cartons.listApplication("C1", 1L)).thenReturn(List.of());
for (long id : List.of(21L, 22L)) {
var profile = new DropshipOrderProfile(); profile.setId(id); profile.setCustomerId("C1"); profile.setPalletNo("P-" + id);
when(profiles.selectById(id)).thenReturn(profile);
}
when(outbounds.findByIdempotency("C1", "CARGO_DISCARD:1")).thenAnswer(i -> document);
when(outbounds.lockByIdempotency("C1", "CARGO_DISCARD:1")).thenAnswer(i -> document);
when(outbounds.insert(any(OutboundOrder.class))).thenAnswer(i -> { document = i.getArgument(0); return 1; });
when(pallets.insert(any(OutboundPalletItem.class))).thenAnswer(i -> { snapshots.add(i.getArgument(0)); return 1; });
when(pallets.findByOutbound(eq("C1"), anyLong())).thenAnswer(i -> snapshots);
}
private CargoDiscardItem line(long id, long profile, int quantity) {
var line = new CargoDiscardItem(); line.setId(id); line.setApplicationId(1L); line.setCustomerId("C1");
line.setProfileId(profile); line.setDiscardPackages(quantity); line.setSkuCode("SKU-" + id); return line;
}
@Test void approvalCreatesExactlyOneDocumentAndImmutablePlannedLines() {
service.create(application, "OP"); service.create(application, "OP");
assertEquals("DISCARD_OUTBOUND", document.getOutboundType()); assertEquals("WAIT_PICK", document.getStatus());
assertEquals(5, document.getPackageCount()); assertEquals(2, document.getPalletCount());
assertTrue(service.outboundNo(application).startsWith("OB-"));
assertEquals(List.of(3, 2), snapshots.stream().map(OutboundPalletItem::getPackageCount).toList());
verify(outbounds, times(1)).insert(any(OutboundOrder.class)); verify(pallets, times(2)).insert(any(OutboundPalletItem.class));
}
@Test void partialThenFinalCompletionTracksActualsWithoutChangingPlannedQuantities() {
service.create(application, "OP"); first.setPdaStatus("COMPLETED"); first.setActualDiscardPackages(2);
service.sync(application); assertEquals("PICKING", document.getStatus());
assertEquals(2, snapshots.get(0).getPickedPackageCount()); assertEquals(0, snapshots.get(1).getPickedPackageCount());
second.setPdaStatus("COMPLETED"); second.setActualDiscardPackages(0);
application.setStatus("PDA_COMPLETED"); application.setTotalPackages(2); service.sync(application);
assertEquals("OUTBOUNDED", document.getStatus()); assertEquals(2, document.getPackageCount());
assertEquals(3, snapshots.get(0).getPackageCount()); assertEquals("PICKED", snapshots.get(1).getStatus());
application.setStatus("COMPLETED"); service.sync(application); assertEquals("OUTBOUNDED", document.getStatus());
}
@Test void cancellationSynchronizesDocumentAndEveryLine() {
service.create(application, "OP"); application.setStatus("CANCELLED"); service.sync(application);
assertEquals("CANCELLED", document.getStatus()); assertTrue(snapshots.stream().allMatch(s -> "CANCELLED".equals(s.getStatus())));
}
@Test void returnSourceKeepsRealCartonMarkWithoutInventingPalletNumber() {
first.setReturnCartonId(31L); first.setSourceType("RETURN_CARTON");
var carton = new ReturnInventoryCarton(); carton.setId(31L); carton.setCustomerId("C1");
carton.setOverseasWarehouseId("99"); carton.setCartonMark("BOX-31");
when(returnCartons.selectById(31L)).thenReturn(carton);
service.create(application, "OP"); assertNull(snapshots.get(0).getPalletNo()); assertEquals("BOX-31", snapshots.get(0).getCartonMarks());
assertEquals(3L, snapshots.get(0).getReturnSkuQuantity());
}
@Test void invalidSourcePreventsAnyOutboundInsert() {
when(profiles.selectById(22L)).thenReturn(null);
assertThrows(ResponseStatusException.class, () -> service.create(application, "OP"));
verify(outbounds, never()).insert(any(OutboundOrder.class)); verify(pallets, never()).insert(any(OutboundPalletItem.class));
}
}
@@ -59,6 +59,7 @@ class CargoDiscardServiceTest {
@Mock ReturnInventoryCartonItemMapper returnCartonItems;
private CargoDiscardService service;
private final CargoDiscardOutboundService outboundService = org.mockito.Mockito.mock(CargoDiscardOutboundService.class);
private final AtomicReference<CargoDiscardApplication> storedApplication = new AtomicReference<>();
private final List<CargoDiscardItem> storedItems = new ArrayList<>();
private final List<CargoDiscardCarton> storedLinks = new ArrayList<>();
@@ -67,6 +68,8 @@ class CargoDiscardServiceTest {
@BeforeEach
void setUp() {
service = new CargoDiscardService();
ReflectionTestUtils.setField(service, "outboundService", outboundService);
ReflectionTestUtils.setField(service, "stockService", org.mockito.Mockito.mock(CargoDiscardStockService.class));
ReflectionTestUtils.setField(service, "photoService", org.mockito.Mockito.mock(CargoDiscardPhotoService.class));
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "items", items);
@@ -96,10 +99,10 @@ class CargoDiscardServiceTest {
LastlegOrder order = order();
DropshipSkuInventory sku = skuInventory();
when(orders.selectById(101L)).thenReturn(order);
when(inventory.countAvailable("100", 101L, "SKU-1")).thenReturn(3L);
when(inventory.countAvailable("100", 101L, "SKU-1")).thenReturn(2L);
when(inventory.lockAvailable("100", 101L, "SKU-1", 2)).thenReturn(List.of(carton(1L), carton(2L)));
when(inventory.lockSkuInventoryDimension("100", 101L, 201L, "SKU-1")).thenReturn(sku);
when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(3L);
when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(2L);
when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("AVAILABLE"), eq("DISCARD_PENDING"))).thenReturn(1);
var detail = service.create("100", "7", new CargoDiscardRequests.Create(
@@ -113,6 +116,30 @@ class CargoDiscardServiceTest {
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
}
@Test void selectedBoxesCreatePartialApplicationWithoutReservingUnselectedStock() {
when(orders.selectById(101L)).thenReturn(order());
when(inventory.lockSelected("100", 101L, "SKU-1", List.of(2L))).thenReturn(List.of(carton(2L)));
when(inventory.lockSkuInventoryDimension("100", 101L, 201L, "SKU-1")).thenReturn(skuInventory());
when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(3L);
when(inventory.availableOrderCartons("100", 101L)).thenReturn(List.of(carton(1L), carton(2L), carton(3L)));
when(inventory.changeCartonStatus("100", 2L, "AVAILABLE", "DISCARD_PENDING")).thenReturn(1);
var detail = service.create("100", "7", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 1, null, List.of(2L))), "partial-create", "damage"));
assertEquals(1, detail.summary().totalPackages()); assertEquals(new BigDecimal("5.00"), detail.summary().totalFee());
assertEquals(List.of("C-2"), detail.items().get(0).cartonMarks());
assertEquals(1, storedLinks.size()); assertEquals(2L, storedLinks.get(0).getCartonId());
org.junit.jupiter.api.Assertions.assertTrue(storedItems.get(0).getCartonScanRequired());
verify(inventory, never()).changeCartonStatus(eq("100"), eq(1L), any(), any());
verify(inventory, never()).changeCartonStatus(eq("100"), eq(3L), any(), any());
}
@Test void duplicatePhysicalBoxAcrossSkuLinesIsRejectedBeforeCreation() {
when(orders.selectById(101L)).thenReturn(order());
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.create("100", "7", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-A", 1, null, List.of(2L)),
new CargoDiscardRequests.Item(101L, null, "SKU-B", 1, null, List.of(2L))), "duplicate-box", null)));
verify(applications, never()).insert(any(CargoDiscardApplication.class));
}
@Test
void candidateTotalCountsPhysicalCartonsInsteadOfSkuRows() {
CargoDiscardCandidateRow skuA = candidate("SKU-A", 20L);
@@ -167,6 +194,7 @@ class CargoDiscardServiceTest {
var detail = service.review("100", "FH-1", "9", new CargoDiscardRequests.Review("APPROVE", "ok"));
assertEquals(CargoDiscardService.APPROVED, detail.summary().status());
verify(outboundService).create(application, "9");
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());
@@ -0,0 +1,132 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.entity.*;
import com.baiyue.lastleg.discard.mapper.*;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.*;
import com.baiyue.lastleg.inventory.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class CargoDiscardStockServiceTest {
final CargoDiscardInventoryMapper stock = mock(CargoDiscardInventoryMapper.class);
final InventoryPositionMapper positions = mock(InventoryPositionMapper.class);
final InventoryBalanceMapper balances = mock(InventoryBalanceMapper.class);
final CargoDiscardCartonMapper links = mock(CargoDiscardCartonMapper.class);
final CargoDiscardItemMapper items = mock(CargoDiscardItemMapper.class);
final InventoryCommandService commands = mock(InventoryCommandService.class);
final CargoDiscardStockService service = new CargoDiscardStockService();
final InventoryPosition position = new InventoryPosition();
final InventoryBalance balance = new InventoryBalance();
final CargoDiscardApplication application = new CargoDiscardApplication();
CargoDiscardRequests.Item request(int count) { return new CargoDiscardRequests.Item(null, null, "SKU", count, 42L); }
@BeforeEach void setup() {
ReflectionTestUtils.setField(service, "stock", stock);
ReflectionTestUtils.setField(service, "positions", positions);
ReflectionTestUtils.setField(service, "balances", balances);
ReflectionTestUtils.setField(service, "commands", commands);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "links", links);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
position.setId(42L); position.setCustomerId("C1"); position.setBalanceId(51L);
position.setOverseasWarehouseId("99"); position.setPalletNo("P-42"); position.setAvailablePackages(100L);
balance.setId(51L); balance.setCustomerId("C1"); balance.setOverseasWarehouseId("99");
balance.setInboundCode("WT-WH-1"); balance.setInboundOrderNo("WT-WH-1"); balance.setSkuCode("SKU"); balance.setAvailablePackages(100L);
application.setId(11L); application.setDiscardNo("CD-1"); application.setCustomerId("C1"); application.setCreatedBy("OP");
when(positions.findTenantPosition("C1", 42L)).thenReturn(position);
when(stock.lockGenericPosition("C1", 42L)).thenReturn(position);
when(balances.selectById(51L)).thenReturn(balance);
}
@Test void warehouseTransferStockIsEligibleAndKeepsTrueSourcePosition() {
when(stock.genericPositions("C1", "WT")).thenReturn(List.of(position));
var candidate = service.candidates("C1", "WT").get(0);
assertEquals("INVENTORY", candidate.sourceType()); assertNull(candidate.orderId());
assertEquals(42L, candidate.inventoryPositionId()); assertEquals("WT-WH-1", candidate.inboundCode());
service.reserve("C1", "OP", application, request(100), LocalDateTime.now());
var capture = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(capture.capture());
var line = capture.getValue(); assertEquals(42L, line.getInventoryPositionId()); assertEquals(100, line.getDiscardPackages());
var move = ArgumentCaptor.forClass(InventoryCommandRequests.Move.class); verify(commands).hold(eq("C1"), eq("OP"), move.capture());
assertEquals(100, move.getValue().packages()); assertEquals("CARGO_DISCARD:11:" + line.getId(), move.getValue().sourceEventId());
service.release("C1", application, line); verify(commands).release(eq("C1"), eq("OP"), any());
service.discard("C1", "OP", application, line, 100); verify(commands).discardHeld(eq("C1"), eq("OP"), any());
}
@Test void partialForeignSkuAndDropshipBypassAreRejectedBeforeHolding() {
assertThrows(ResponseStatusException.class, () -> service.reserve("C1", "OP", application, request(99), LocalDateTime.now()));
assertThrows(ResponseStatusException.class, () -> service.validate("C2", request(100)));
assertThrows(ResponseStatusException.class, () -> service.validate("C1", new CargoDiscardRequests.Item(null, null, "OTHER", 100, 42L)));
when(stock.isDropshipPosition("C1", 42L)).thenReturn(1L);
assertThrows(ResponseStatusException.class, () -> service.validate("C1", request(100)));
verifyNoInteractions(commands, items);
}
@Test void looseStockRequiresCompleteRealMarksAndSnapshotsThem() {
position.setPalletNo(null); position.setAvailablePackages(2L); balance.setAvailablePackages(2L);
when(stock.looseCartonMarks("C1", 42L)).thenReturn(List.of("BOX-1"));
assertThrows(ResponseStatusException.class, () -> service.reserve("C1", "OP", application, request(2), LocalDateTime.now()));
verifyNoInteractions(commands, items);
when(stock.looseCartonMarks("C1", 42L)).thenReturn(List.of("BOX-1", "BOX-2"));
service.reserve("C1", "OP", application, request(2), LocalDateTime.now());
var capture = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(capture.capture());
assertEquals(List.of("BOX-1", "BOX-2"), service.cartonMarks(capture.getValue()));
}
private com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow carton(long id, String mark) {
var carton = new com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow();
carton.setSourceType("INBOUND"); carton.setId(id); carton.setCartonMark(mark); carton.setSkuCode("SKU"); return carton;
}
@Test void partialDisposalFreezesExactlySelectedBoxesAndLeavesTheOthersUntouched() {
position.setAvailablePackages(3L); balance.setAvailablePackages(3L);
when(stock.genericCartons("C1", 42L)).thenReturn(List.of(carton(1L, "BOX-1"), carton(2L, "BOX-2"), carton(3L, "BOX-3")));
when(stock.changeGenericCartonStatus("C1", 2L, "AVAILABLE", "DISCARD_PENDING")).thenReturn(1);
var request = new CargoDiscardRequests.Item(null, null, "SKU", 1, 42L, List.of(2L));
service.reserve("C1", "OP", application, request, LocalDateTime.now());
var capture = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(capture.capture());
var line = capture.getValue(); assertEquals(1, line.getDiscardPackages()); assertTrue(line.getCartonScanRequired());
assertEquals(List.of("BOX-2"), service.cartonMarks(line));
var boxLink = ArgumentCaptor.forClass(CargoDiscardCarton.class); verify(links).insert(boxLink.capture());
assertEquals(2L, boxLink.getValue().getCartonId());
verify(stock, never()).changeGenericCartonStatus(eq("C1"), eq(1L), anyString(), anyString());
verify(stock, never()).changeGenericCartonStatus(eq("C1"), eq(3L), anyString(), anyString());
when(links.listApplication("C1", 11L)).thenReturn(List.of(boxLink.getValue()));
when(stock.changeGenericCartonStatus("C1", 2L, "DISCARD_PENDING", "AVAILABLE")).thenReturn(1);
service.release("C1", application, line);
verify(stock).changeGenericCartonStatus("C1", 2L, "DISCARD_PENDING", "AVAILABLE");
}
@Test void partialBoxSelectionRejectsForeignRepeatedAndMismatchedQuantity() {
position.setAvailablePackages(3L); balance.setAvailablePackages(3L);
when(stock.genericCartons("C1", 42L)).thenReturn(List.of(carton(1L, "BOX-1"), carton(2L, "BOX-2"), carton(3L, "BOX-3")));
for (var request : List.of(new CargoDiscardRequests.Item(null, null, "SKU", 1, 42L, List.of(9L)),
new CargoDiscardRequests.Item(null, null, "SKU", 2, 42L, List.of(1L, 1L)),
new CargoDiscardRequests.Item(null, null, "SKU", 2, 42L, List.of(1L)))) {
assertThrows(ResponseStatusException.class, () -> service.validate("C1", request));
}
verifyNoInteractions(commands, items, links);
}
@Test void singlePositionTransferBoxesReleaseBackToTheirOriginalExpectedStatus() {
position.setAvailablePackages(2L); balance.setAvailablePackages(2L);
var first = carton(1L, "WT-1#2-1"); first.setSourceType("TRANSFER");
var second = carton(2L, "WT-1#2-2"); second.setSourceType("TRANSFER");
when(stock.genericCartons("C1", 42L)).thenReturn(List.of(first, second));
when(stock.changeTransferCartonStatus("C1", 2L, "EXPECTED", "DISCARD_PENDING")).thenReturn(1);
service.reserve("C1", "OP", application, new CargoDiscardRequests.Item(null, null, "SKU", 1, 42L, List.of(2L)), LocalDateTime.now());
var capture = ArgumentCaptor.forClass(CargoDiscardCarton.class); verify(links).insert(capture.capture());
assertEquals("TRANSFER", capture.getValue().getSourceType());
when(links.listApplication("C1", 11L)).thenReturn(List.of(capture.getValue()));
when(stock.changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "EXPECTED")).thenReturn(1);
var line = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(line.capture());
service.release("C1", application, line.getValue());
verify(stock).changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "EXPECTED");
verify(stock, never()).changeGenericCartonStatus(anyString(), anyLong(), anyString(), anyString());
}
}
@@ -594,6 +594,35 @@ class InventoryCommandServiceTest {
"return-1", List.of("FILE-1"));
}
@Test void discardOnlyConsumesOwnedHoldAndKeepsAvailableStockConserved() {
var balance = balance(200L, 7); balance.setHoldPackages(3L);
var position = position(201L, 200L, 7); position.setHoldPackages(3L);
when(commands.lockPosition("100", 201L)).thenReturn(position);
when(commands.lockBalance("100", 200L)).thenReturn(balance);
var hold = new InventoryLedger(); hold.setPositionId(201L); hold.setBusinessOrderNo("CD-1"); hold.setDeltaPackages(3L);
when(ledgers.orderHoldCycle("100", 101L, "CARGO_DISCARD:1:2")).thenReturn(List.of(hold));
when(commands.palletPackages(300L)).thenReturn(7L);
service.discardHeld("100", "OP", new InventoryCommandRequests.Move(201L, 3, "CD-1", "OB-1", "CARGO_DISCARD:1:2", "DISCARD-1", null));
assertEquals(7L, position.getAvailablePackages()); assertEquals(7L, balance.getAvailablePackages());
assertEquals(0L, position.getHoldPackages()); assertEquals(0L, balance.getHoldPackages());
var capture = ArgumentCaptor.forClass(InventoryLedger.class); verify(ledgers).insert(capture.capture());
assertEquals("DISCARD", capture.getValue().getBusinessType()); assertEquals(-3L, capture.getValue().getDeltaPackages());
assertEquals("OB-1", capture.getValue().getOutboundOrderNo());
verify(warehouseGateway, never()).scrapPallet(any(), org.mockito.ArgumentMatchers.anyLong());
}
@Test void foreignDiscardHoldCannotConsumeAnotherApplicationsStock() {
var balance = balance(200L, 0); balance.setHoldPackages(3L);
var position = position(201L, 200L, 0); position.setHoldPackages(3L);
when(commands.lockPosition("100", 201L)).thenReturn(position);
when(commands.lockBalance("100", 200L)).thenReturn(balance);
var hold = new InventoryLedger(); hold.setPositionId(201L); hold.setBusinessOrderNo("OTHER-CD"); hold.setDeltaPackages(3L);
when(ledgers.orderHoldCycle("100", 101L, "CARGO_DISCARD:1:2")).thenReturn(List.of(hold));
assertThrows(ResponseStatusException.class, () -> service.discardHeld("100", "OP", new InventoryCommandRequests.Move(201L, 3, "CD-1", null, "CARGO_DISCARD:1:2", "DISCARD-1", null)));
assertEquals(3L, position.getHoldPackages());
verify(positions, never()).updateById(any(InventoryPosition.class)); verify(ledgers, never()).insert(any(InventoryLedger.class));
}
private InventoryBalance balance(Long id, long available) {
InventoryBalance value = new InventoryBalance();
value.setId(id); value.setCustomerId("100"); value.setCustomerName("Customer 100");
@@ -53,6 +53,7 @@ class PdaRelabelServiceTest {
@Mock RelabelApplicationService applicationService;
@Mock RelabelPhotoFileGateway photoFiles;
@Mock com.baiyue.lastleg.relabel.service.ReturnSkuConversionService skuConversions;
@Mock com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService palletMeasurements;
PdaRelabelService service;
RelabelApplication application;
RelabelItem item;
@@ -62,6 +63,7 @@ class PdaRelabelServiceTest {
void setUp() {
service = new PdaRelabelService();
ReflectionTestUtils.setField(service, "skuConversions", skuConversions);
ReflectionTestUtils.setField(service, "palletMeasurements", palletMeasurements);
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "bindingGuard", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.RelabelBindingGuard.class));
ReflectionTestUtils.setField(service, "items", items);
@@ -452,12 +454,44 @@ class PdaRelabelServiceTest {
new PdaRelabelRequests.Complete(List.of(501L), 1, "complete-1", "done"));
verify(photoFiles).validate("100", 20L, 501L);
verify(palletMeasurements).requireMeasured(application);
verify(skuConversions).apply(eq(application), org.mockito.ArgumentMatchers.anyList(), org.mockito.ArgumentMatchers.anyList(), anyString(), org.mockito.ArgumentMatchers.anyList());
assertEquals(PdaRelabelService.WAIT_REVIEW, result.taskStatus());
assertEquals("CUSTOMER_CONFIRM_PENDING", result.applicationStatus());
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("7"), any());
}
@Test
void missingOriginalRemainderMeasurementBlocksNormalAndManualCompletionBeforeWrites() {
application.setStatus("PROCESSING");
PdaRelabelTask task = task("PROCESSING", 1);
task.setRelabelledPackages(1);
persistedFacts.add(fact("RELABELLED"));
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
var measurements = org.mockito.Mockito.spy(new com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService());
var measuredNew = new com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService.Measurement(
200L, "NEW", 1, "2:1:1", java.math.BigDecimal.TEN, java.math.BigDecimal.TEN,
java.math.BigDecimal.TEN, java.math.BigDecimal.ONE, new java.math.BigDecimal("0.001"), false);
var unmeasuredRemainder = new com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService.Measurement(
100L, "OLD", 2, "1:3:2", null, null, null, null, null, false);
org.mockito.Mockito.doReturn(List.of(measuredNew, unmeasuredRemainder)).when(measurements).snapshots(application);
ReflectionTestUtils.setField(service, "palletMeasurements", measurements);
for (boolean manual : List.of(false, true)) {
var error = assertThrows(ResponseStatusException.class, () -> service.complete("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Complete(List.of(501L), 1, "complete-" + manual, "done", manual)));
assertTrue(error.getReason().contains("托盘 OLD"));
assertEquals("PROCESSING", task.getStatus());
assertEquals("PROCESSING", application.getStatus());
}
verify(tasks, never()).updateWithVersion(any(), anyInt());
verify(unitFacts, never()).insert(any(PdaRelabelUnitFact.class));
org.mockito.Mockito.verifyNoInteractions(applicationService, photoFiles, skuConversions);
verify(histories, never()).insert(any(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory.class));
verify(operations, never()).insert(any(PdaRelabelOperation.class));
}
@Test
void approvalUsesExistingApplicationCompletionAndReworkDoesNot() {
assertThrows(ResponseStatusException.class, () -> service.review("8", "reviewer", 900L, "FBR-1",
+30
View File
@@ -1388,9 +1388,13 @@ 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 '产品名称',
inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID',
carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组',
carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子',
available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '废弃箱数',
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),
UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code),
KEY idx_ll_cargo_discard_item_list(customer_id,application_id),
KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code)
) ENGINE=InnoDB COMMENT='货物废弃SKU明细';
@@ -1398,6 +1402,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton (
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛',
source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id),
KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id),
@@ -5079,3 +5084,28 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 新签收及新合箱必须打印并扫码确认标签;既有实物按原状态继续作业。
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='label_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN label_status VARCHAR(32) NULL COMMENT ''WAIT_LABEL待贴标 CONFIRMED已扫码确认标签''');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 货物废弃支持所有入库来源及散箱全量作业;不回填或修改历史单据。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='inventory_position_id'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN inventory_position_id BIGINT NULL COMMENT ''废弃来源库存仓位ID''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_marks_json'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_marks_json TEXT NULL COMMENT ''本次废弃所选真实箱唛快照JSON数组''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='uk_ll_discard_position_sku'),
'ALTER TABLE ll_cargo_discard_item ADD UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY';
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_scan_required'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要逐箱核对本次所选箱子''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组';
ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED';
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='source_type'),
'ALTER TABLE ll_cargo_discard_carton ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;