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