This commit is contained in:
2026-10-20 11:30:34 +08:00
parent c13175bf77
commit 16ac817524
92 changed files with 1916 additions and 190 deletions
@@ -11,20 +11,22 @@ import java.util.HashSet;
public class OutboundQuantityRepository {
@Resource private OutboundPalletItemMapper pallets;
/** 废弃退货来源依据已冻结箱唛计数,读取已有单据也不会把件数当箱数。 */
/** 废弃来源依据已冻结箱唛去重计数,无箱唛的库存明细保留箱数快照。 */
public Integer boxes(OutboundOrder outbound) {
if (!"DISCARD_OUTBOUND".equals(outbound.getOutboundType())) return outbound.getPackageCount();
var lines = pallets.findByOutbound(outbound.getCustomerId(), outbound.getId());
if (lines.isEmpty()) return outbound.getPackageCount();
var returnBoxes = new HashSet<String>();
var markedBoxes = new HashSet<String>();
int count = 0;
for (var line : lines) {
if (line.getReturnSkuQuantity() != null && line.getCartonMarks() != null && !line.getCartonMarks().isBlank()) {
returnBoxes.add(line.getInboundCode() + ":" + line.getCartonMarks());
if (line.getCartonMarks() != null && !line.getCartonMarks().isBlank()) {
// 同一箱的多个 SKU 共用箱唛,只计一个实物箱;一行也可能冻结多个箱唛。
java.util.Arrays.stream(line.getCartonMarks().split(",")).map(String::trim)
.filter(mark -> !mark.isEmpty()).forEach(markedBoxes::add);
} else {
count = Math.addExact(count, java.util.Objects.requireNonNullElse(line.getPackageCount(), 0));
}
}
return Math.addExact(count, returnBoxes.size());
return Math.addExact(count, markedBoxes.size());
}
}
@@ -130,6 +130,7 @@ public class DeliveryTransferService {
@Resource private DeliveryExecutionGuard deliveryExecution;
@Resource private com.baiyue.lastleg.dispatch.port.DispatchExecutionPort execution;
@Resource private com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository cancellations;
@Resource private com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository outboundQuantities;
@Transactional
public DeliveryResponses.PdaTask createOneClick(String customerId, String operatorId, DeliveryRequests.OneClickOutbound request) {
@@ -1341,7 +1342,11 @@ public class DeliveryTransferService {
} catch (JsonProcessingException exception) { throw new IllegalStateException("费用覆盖确认无法读取", exception); }
}
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); }
/** 废弃出库按冻结实物箱汇总,避免历史件数快照被展示为箱数。 */
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) {
Integer boxes = "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? outboundQuantities.boxes(o) : o.getPackageCount();
return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), boxes, o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType());
}
private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) {
return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(),
item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(),
@@ -46,6 +46,14 @@ public class CargoDiscardController {
return ApiResponse.success(service.page(scope(user, customerId), query));
}
/** 读取全部筛选结果的数量统计,与分页记录分开返回。 */
@GetMapping("/statistics")
public ApiResponse<com.baiyue.lastleg.discard.service.CargoDiscardStatisticsService.Statistics> statistics(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@Valid CargoDiscardRequests.Query query) {
return ApiResponse.success(service.statistics(scope(user, customerId), query));
}
@GetMapping("/{discardNo}")
public ApiResponse<CargoDiscardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String discardNo) {
@@ -32,6 +32,14 @@ public class MerchantCargoDiscardController {
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
public ApiResponse<PageResult<CargoDiscardResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid CargoDiscardRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); }
/** 客户统计只读取当前认证客户,不接受客户端客户归属。 */
@GetMapping("/statistics")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
public ApiResponse<com.baiyue.lastleg.discard.service.CargoDiscardStatisticsService.Statistics> statistics(
@AuthenticationPrincipal AuthenticatedUser user, @Valid CargoDiscardRequests.Query query) {
return ApiResponse.success(service.statistics(customer(user), query));
}
@GetMapping("/{discardNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
public ApiResponse<CargoDiscardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@@ -33,5 +33,12 @@ public final class CargoDiscardRequests {
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
String discardNo, String inboundCode, String overseasWarehouseId,
String status, LocalDateTime createdFrom, LocalDateTime createdTo) {}
String status, LocalDateTime createdFrom, LocalDateTime createdTo,
LocalDateTime completedFrom, LocalDateTime completedTo) {
/** 未筛完成时间的调用保持创建时间查询语义。 */
public Query(long page, long size, String discardNo, String inboundCode, String overseasWarehouseId,
String status, LocalDateTime createdFrom, LocalDateTime createdTo) {
this(page, size, discardNo, inboundCode, overseasWarehouseId, status, createdFrom, createdTo, null, null);
}
}
}
@@ -37,7 +37,12 @@ public final class CargoDiscardResponses {
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt) {}
public record Item(Long id, Long orderId, String inboundCode, String skuCode,
String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks) {
String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks,
String quantityUnit, Integer actualDiscardPackages, String pdaStatus) {
/** 无来源事实的调用不猜测数量单位或实际完成数。 */
public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks) {
this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, cartonMarks, null, null, null);
}
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());
}
@@ -25,6 +25,7 @@ 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);
/** 按实收托盘及 SKU 关联已清点箱,避免漏箱或混入其他托盘的箱号。 */
@Select("""
SELECT c.id,c.carton_mark,c.sku_code,'INBOUND' source_type
FROM ll_inbound_plan_carton c
@@ -38,13 +39,13 @@ public interface CargoDiscardInventoryMapper {
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_pallet tp ON tp.customer_id=t.customer_id AND tp.transfer_order_id=t.id
AND tp.warehouse_pallet_id=p.pallet_id AND tp.status='COMPLETED'
JOIN ll_warehouse_transfer_carton c ON c.customer_id=t.customer_id AND c.transfer_order_id=t.id
AND c.transfer_pallet_id=tp.id AND c.status='COUNTED'
JOIN ll_warehouse_transfer_item i ON i.customer_id=c.customer_id AND i.id=c.transfer_item_id
AND i.transfer_order_id=t.id AND COALESCE(i.sku_code,'')=COALESCE(b.sku_code,'')
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);
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.discard.model;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
/** 废弃作业沿用签收时实物标签,兼容历史包裹内部 RP 编号。 */
public final class CargoDiscardReturnMark {
/** 工具类不允许实例化。 */
private CargoDiscardReturnMark() { }
/** 包裹取签收单元编码;箱子及缺少来源编码的历史数据保留原箱唛。 */
public static String of(ReturnInventoryCarton carton) {
if (carton == null) return null;
if ("PARCEL".equals(carton.getSourceUnitType()) && carton.getSourceUnitCode() != null
&& !carton.getSourceUnitCode().isBlank()) return carton.getSourceUnitCode().trim();
return carton.getCartonMark();
}
}
@@ -201,7 +201,8 @@ public class PdaCargoDiscardService {
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());
String mark = com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton);
return mark == null ? List.of() : List.of(mark);
}
return cartons.listApplication(application.getCustomerId(), application.getId()).stream()
.filter(c -> Objects.equals(c.getItemId(), item.getId())).map(CargoDiscardCarton::getCartonMark).filter(Objects::nonNull).toList();
@@ -73,7 +73,7 @@ public class CargoDiscardOutboundService {
|| !application.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId()))
throw conflict("废弃明细关联退货箱不存在或仓库不匹配");
palletNo = carton.getPalletCode() == null || carton.getPalletCode().isBlank() ? null : carton.getPalletCode();
cartonMarks = carton.getCartonMark();
cartonMarks = com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton);
} else {
var profile = profiles.selectById(line.getProfileId());
if (profile == null || !application.getCustomerId().equals(profile.getCustomerId()))
@@ -108,6 +108,12 @@ public class CargoDiscardOutboundService {
var line = lines.stream().filter(i -> Objects.equals(i.getId(), snapshot.getId())).findFirst()
.orElseThrow(() -> conflict("废弃出库明细关联失效"));
boolean returned = line.getReturnCartonId() != null;
if (returned) {
var carton = returnCartons.selectById(line.getReturnCartonId());
if (carton == null || !application.getCustomerId().equals(carton.getCustomerId()))
throw conflict("废弃明细关联退货箱不存在");
snapshot.setCartonMarks(com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton));
}
int boxes = returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages();
snapshot.setPackageCount(boxes);
if ("COMPLETED".equals(line.getPdaStatus())) {
@@ -50,6 +50,7 @@ import java.util.Set;
@Service
public class CargoDiscardService {
@Resource private CargoDiscardStatisticsService statisticsService;
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository quantities;
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
public static final String APPROVAL_PERMISSION = "platform:overseas-warehouse:discard:approve";
@@ -97,14 +98,14 @@ public class CargoDiscardService {
if (returnCartons == null || returnCartonItems == null) return result;
for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, null)) {
if (StringUtils.hasText(keyword) && !containsIgnoreCase(carton.getSourceReturnNo(), keyword)
&& !containsIgnoreCase(carton.getCartonMark(), keyword)) continue;
&& !containsIgnoreCase(com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton), keyword)) continue;
List<CargoDiscardResponses.CandidateSku> skus = returnCartonItems.listCarton(carton.getId()).stream()
.filter(item -> value(item.getQuantity()) > 0)
.map(item -> new CargoDiscardResponses.CandidateSku(item.getSkuCode(), item.getProductNameZh(), value(item.getQuantity())))
.toList();
long available = skus.stream().mapToLong(CargoDiscardResponses.CandidateSku::availablePackages).sum();
if (available <= 0) continue;
result.add(new CargoDiscardResponses.Candidate(null, "RETURN_CARTON", carton.getId(), carton.getCartonMark(),
result.add(new CargoDiscardResponses.Candidate(null, "RETURN_CARTON", carton.getId(), com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton),
carton.getSourceReturnNo(), carton.getSourceReturnNo(), carton.getOverseasWarehouseId(), null,
null, available, skus));
}
@@ -253,7 +254,8 @@ public class CargoDiscardService {
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());
String mark = com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton);
return mark == null ? List.of() : List.of(mark);
}
return discardCartons.listApplication(customer, application.getId()).stream().filter(link -> link.getItemId().equals(line.getId()))
.map(CargoDiscardCarton::getCartonMark).distinct().sorted().toList();
@@ -261,16 +263,7 @@ public class CargoDiscardService {
public PageResult<CargoDiscardResponses.Summary> page(String customerId, CargoDiscardRequests.Query query) {
validateQuery(query);
LambdaQueryWrapper<CargoDiscardApplication> wrapper = new LambdaQueryWrapper<CargoDiscardApplication>()
.eq(StringUtils.hasText(customerId), CargoDiscardApplication::getCustomerId, customerId)
.like(StringUtils.hasText(query.discardNo()), CargoDiscardApplication::getDiscardNo, query.discardNo())
.like(StringUtils.hasText(query.inboundCode()), CargoDiscardApplication::getInboundCodes, query.inboundCode())
.eq(StringUtils.hasText(query.overseasWarehouseId()), CargoDiscardApplication::getOverseasWarehouseId,
query.overseasWarehouseId())
.eq(StringUtils.hasText(query.status()), CargoDiscardApplication::getStatus, query.status())
.ge(query.createdFrom() != null, CargoDiscardApplication::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, CargoDiscardApplication::getCreatedAt, query.createdTo())
.orderByDesc(CargoDiscardApplication::getCreatedAt).orderByDesc(CargoDiscardApplication::getId);
var wrapper = com.baiyue.lastleg.discard.repository.CargoDiscardQueryRepository.filter(customerId, query);
Page<CargoDiscardApplication> result = applications.selectPage(Page.of(query.page(), query.size()), wrapper);
Map<String, String> names = result.getRecords().stream().anyMatch(a -> !StringUtils.hasText(a.getOverseasWarehouseName()))
? warehouseNames() : Map.of();
@@ -278,12 +271,19 @@ public class CargoDiscardService {
return new PageResult<>(records, query.page(), query.size(), result.getTotal(), result.getPages());
}
/** 全筛选汇总与列表使用同一输入校验,不受当前分页影响。 */
public CargoDiscardStatisticsService.Statistics statistics(String customerId, CargoDiscardRequests.Query query) {
validateQuery(query);
return statisticsService.statistics(customerId, query);
}
public CargoDiscardResponses.Detail detail(String customerId, String discardNo) {
CargoDiscardApplication application = applications.findNo(customerId, discardNo);
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(), selectedMarks(customerId, application, value)))
value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages(), selectedMarks(customerId, application, value),
value.getReturnCartonId() != null ? "件" : "箱", value.getActualDiscardPackages(), value.getPdaStatus()))
.toList();
List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(),
@@ -528,6 +528,9 @@ public class CargoDiscardService {
}
private void validateQuery(CargoDiscardRequests.Query query) {
if (query.completedFrom() != null && query.completedTo() != null && query.completedFrom().isAfter(query.completedTo())) {
throw badRequest("completedFrom must not be after completedTo");
}
if (StringUtils.hasText(query.status()) && !STATUSES.contains(query.status())) throw badRequest("Unsupported discard status");
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) {
throw badRequest("createdFrom must not be after createdTo");
@@ -66,6 +66,7 @@ public class CargoDiscardStockService {
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);
@@ -80,7 +81,7 @@ public class CargoDiscardStockService {
: 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("箱子已被其他作业占用,请刷新后重选");
if (changeBox(customer, carton.getId(), carton.getSourceType(), "TRANSFER".equals(carton.getSourceType()) ? "COUNTED" : "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);
}
@@ -115,9 +116,10 @@ public class CargoDiscardStockService {
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)
if (Objects.equals(link.getItemId(), line.getId()) && changeBox(customer, link.getCartonId(), link.getSourceType(), "DISCARD_PENDING", "TRANSFER".equals(link.getSourceType()) && "AVAILABLE".equals(status) ? "COUNTED" : status) != 1)
throw conflict("预留箱子的库存状态发生变化");
}
}
@@ -5,6 +5,11 @@ import java.util.List;
/** BOL按真实明细客户逐行读取整车,不依赖已移除的主单客户头。 */
@Mapper
public interface DispatchBolMapper {
/** 旧客户详情入口沿用整车查询,但只返回该客户自己的BOL明细。 */
default List<com.baiyue.lastleg.dispatch.dto.DispatchResponse.BolLine> findByDispatchOrder(String customerId, Long dispatchOrderId) {
return findWholeDispatch(dispatchOrderId).stream().filter(line -> java.util.Objects.equals(customerId,line.customerId())).map(line -> new com.baiyue.lastleg.dispatch.dto.DispatchResponse.BolLine(line.containerNo(),line.palletNo(),line.fbaWarehouseCode(),line.destinationWarehouseName(),line.warehouseNo(),line.fbaShipmentId(),line.poReferenceNo(),line.packageCount())).toList();
}
@Select("SELECT di.customer_id AS customerId,di.destination_type AS destinationType,di.delivery_address AS deliveryAddress,di.postal_code AS postalCode," +
"COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," +
"MAX(NULLIF(di.pallet_no,'')) AS palletNo," +
@@ -17,7 +17,7 @@ public final class OverseasProcurementRequests {
public record Create(
@Size(max = 255) String customerName,
@NotBlank @Size(max = 128) String purchaseCategory,
@Size(max = 128) String purchaseCategory,
@NotBlank @Size(max = 255) String productName,
@Size(max = 500) String productSpecification,
@Size(max = 2000) String productUrl,
@@ -75,7 +75,7 @@ public class OverseasProcurementService {
order.setCustomerId(customerId);
order.setCustomerName(trim(request.customerName()));
order.setOrderNo(orderNoGenerator.next(customerId, request.customerName()));
order.setPurchaseCategory(request.purchaseCategory().trim());
order.setPurchaseCategory(request.purchaseCategory() == null ? "" : request.purchaseCategory().trim());
order.setProductName(request.productName().trim());
order.setProductSpecification(trim(request.productSpecification()));
order.setProductUrl(trim(request.productUrl()));
@@ -25,6 +25,15 @@ import java.util.List;
@RequestMapping("/api/merchant/v1/last-leg/relabel-applications")
public class MerchantRelabelApplicationController {
@Resource private RelabelApplicationService service;
@Resource private com.baiyue.lastleg.relabel.service.RelabelQuantityService quantities;
/** 客户汇总始终取认证客户范围,不接收客户端客户ID。 */
@GetMapping("/quantity-summary")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')")
public ApiResponse<com.baiyue.lastleg.relabel.service.RelabelQuantityService.Totals> quantities(
@AuthenticationPrincipal AuthenticatedUser user, @Valid RelabelRequests.Query query) {
return ApiResponse.success(quantities.totals(customer(user), query));
}
@GetMapping("/inventory-candidates")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')")
public ApiResponse<List<RelabelResponses.InventoryCandidate>> candidates(
@@ -25,6 +25,16 @@ import org.springframework.web.server.ResponseStatusException;
@RequestMapping("/api/platform/v1/last-leg/relabel-applications")
public class RelabelApplicationController {
@Resource private RelabelApplicationService service;
@Resource private com.baiyue.lastleg.relabel.service.RelabelQuantityService quantities;
/** 按平台当前全部筛选结果返回独立数量维度。 */
@GetMapping("/quantity-summary")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
public ApiResponse<com.baiyue.lastleg.relabel.service.RelabelQuantityService.Totals> quantities(
@AuthenticationPrincipal AuthenticatedUser user, @Valid RelabelRequests.Query query) {
requirePlatform(user);
return ApiResponse.success(quantities.totals(null, query));
}
@Resource private com.baiyue.lastleg.relabel.pda.service.PdaRelabelService pdaService;
@PostMapping("/{relabelNo}/rework")
@@ -570,30 +570,7 @@ public class RelabelApplicationService implements WalletResultHandler {
@Transactional(readOnly = true)
public PageResult<RelabelResponses.Summary> page(String customerId, RelabelRequests.Query query) {
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) {
throw badRequest("createdFrom must not be after createdTo");
}
String requestedStatus = trim(query.status());
LambdaQueryWrapper<RelabelApplication> wrapper = new LambdaQueryWrapper<RelabelApplication>()
.eq(StringUtils.hasText(customerId), RelabelApplication::getCustomerId, customerId)
.like(StringUtils.hasText(query.relabelNo()), RelabelApplication::getRelabelNo, query.relabelNo())
.like(StringUtils.hasText(query.customerOrderNo()), RelabelApplication::getCustomerOrderNo, query.customerOrderNo())
.eq(StringUtils.hasText(query.overseasWarehouseId()), RelabelApplication::getOverseasWarehouseId,
trim(query.overseasWarehouseId()))
.ge(query.createdFrom() != null, RelabelApplication::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, RelabelApplication::getCreatedAt, query.createdTo())
.apply(StringUtils.hasText(query.skuCode()),
"EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=ll_relabel_application.id AND ri.customer_id=ll_relabel_application.customer_id AND ri.sku_code LIKE CONCAT('%',{0},'%'))",
query.skuCode())
.orderByDesc(RelabelApplication::getCreatedAt);
if (PENDING_PROCESSING.equals(requestedStatus)) {
wrapper.in(RelabelApplication::getStatus, PENDING_PROCESSING, CUSTOMER_CONFIRM_PENDING,
"REJECTED", "CANCELLED");
} else if ("COMPLETED".equals(requestedStatus)) {
wrapper.in(RelabelApplication::getStatus, READY_FOR_OUTBOUND, "COMPLETED");
} else if (StringUtils.hasText(requestedStatus)) {
wrapper.eq(RelabelApplication::getStatus, requestedStatus);
}
LambdaQueryWrapper<RelabelApplication> wrapper = com.baiyue.lastleg.relabel.model.RelabelQueryFilter.build(customerId, query);
Page<RelabelApplication> page = applications.selectPage(new Page<>(query.page(), query.size()), wrapper);
List<RelabelResponses.Summary> records = page.getRecords().stream().map(value -> summary(value, itemList(value.getId()))).toList();
return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
@@ -529,6 +529,7 @@ public class ReturnInventoryService {
if (cartons.updateById(carton) != 1) throw conflict("退货箱状态已变化,请重新核对");
}
/** 包裹直接沿用签收实物编码,避免同一实物生成两套标签。 */
@Transactional
public ReturnInventoryCarton createReceiptCarton(ReturnOrder order, ReturnReceipt receipt,
String requestedMark, List<ReceiptLine> lines) {
@@ -537,8 +538,8 @@ public class ReturnInventoryService {
required(lines.get(0).skuCode(), "SKU 不能为空");
String cartonMark = text(requestedMark);
long cartonId = IdWorker.getId();
// One durable barcode identifies a physical parcel, irrespective of SKU count.
if ("PARCEL".equals(receipt.getUnitType())) cartonMark = "RP-" + Long.toString(receipt.getId(), 36).toUpperCase(java.util.Locale.ROOT);
// 包裹签收已生成 PK 标签,后续库存、废弃和扫码沿用同一编码。
if ("PARCEL".equals(receipt.getUnitType())) cartonMark = normalizedUnitCode;
else if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId);
if (cartons.findMark(cartonMark) != null) throw conflict("箱号已存在: " + cartonMark);
LocalDateTime now = LocalDateTime.now();
@@ -458,7 +458,9 @@ public class ReturnReceiptService {
RelabelApplication app = new RelabelApplication(); app.setId(appId); app.setCustomerId(customerId); app.setRelabelNo(relabelNo); app.setLastlegOrderId(tail.getId());
app.setCustomerOrderNo(order.getReturnNo()); app.setOverseasWarehouseId(order.getOverseasWarehouseId());
app.setLabelDeadline(now.plusDays(7));
app.setContactName(text(order.getWarehouseContactName())); app.setContactPhone(text(order.getWarehouseContactPhone()));
// 退货预报的仓库联系人选填;转换标时保留空值语义并满足申请表非空约束。
app.setContactName(java.util.Objects.requireNonNullElse(text(order.getWarehouseContactName()), ""));
app.setContactPhone(java.util.Objects.requireNonNullElse(text(order.getWarehouseContactPhone()), ""));
app.setFulfillmentStatus("WAIT_APPROVAL"); app.setRemark(text(request.cartonRemark())); app.setStatus("PENDING_REVIEW"); app.setItemCount(request.items().size()); app.setTotalPackageCount(Math.toIntExact(totalQuantity)); app.setNotifiedPackageCount(Math.toIntExact(totalQuantity)); app.setChargeAmount(java.math.BigDecimal.ZERO); app.setChargeCurrency("CNY"); app.setWalletStatus("NOT_REQUIRED"); app.setIdempotencyKey(key); app.setRequestHash(requestHash); app.setCreatedBy(text(operatorId)); app.setCreatedAt(now); app.setUpdatedAt(now); relabelApplications.insert(app);
List<ReturnInventoryCarton> availableCartons = cartons.findAvailable(customerId, order.getOverseasWarehouseId());
Map<Long, ReturnInventoryCarton> selectedCartons = new LinkedHashMap<>();
@@ -577,6 +579,11 @@ public class ReturnReceiptService {
String unitType = norm(request.unitType());
if (unitType == null) unitType = "PALLET";
String unitCode = text(request.unitCode());
// 箱和包裹编码会直接生成 Code128 标签,入账前拒绝无法打印的编码。
if (unitCode != null && Set.of("BOX", "PARCEL").contains(unitType)) {
try { new com.google.zxing.oned.Code128Writer().encode(unitCode); }
catch (IllegalArgumentException invalid) { throw badRequest("实物编码无法生成条码,请使用 ASCII 编码"); }
}
ReturnOrder order = orders.lockNo(text(request.returnNo()));
if (order == null || !String.valueOf(identity.overseasWarehouseId()).equals(order.getOverseasWarehouseId())) {
throw notFound("当前绑定仓没有该退货单");
@@ -869,3 +876,4 @@ public class ReturnReceiptService {
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
private record Allocation(ReturnOrderItem item, long quantity) { }
}
@@ -40,6 +40,28 @@ import org.apache.ibatis.annotations.Select;
)
UNION ALL
SELECT c.carton_mark,c.sku_code,NULL,c.product_quantity,NULL,NULL,
(SELECT JSON_ARRAYAGG(JSON_OBJECT('skuCode',s.sku_code,'quantity',s.product_quantity))
FROM ll_dropship_carton_sku s WHERE s.customer_id=c.customer_id AND s.carton_id=c.id)
FROM ll_dropship_carton c
JOIN ll_order o ON o.id=c.order_id AND o.customer_id=c.customer_id
JOIN ll_dropship_order_profile p ON p.id=c.profile_id AND p.customer_id=c.customer_id
JOIN ll_inventory_position pos ON pos.id=p.inventory_position_id AND pos.customer_id=c.customer_id
JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=c.customer_id
WHERE c.customer_id=#{customerId} AND o.inbound_code=#{inboundCode}
AND o.overseas_warehouse_id=#{warehouseId} AND c.status='AVAILABLE'
AND p.status='ACTIVE' AND pos.available_packages>0 AND b.available_packages>0
AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_carton existing
WHERE existing.customer_id=c.customer_id AND existing.order_id=c.order_id
AND existing.carton_mark=c.carton_mark)
AND NOT EXISTS (
SELECT 1 FROM ll_relabel_item ri
JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id
WHERE ri.customer_id=c.customer_id AND ri.source_inbound_code=o.inbound_code
AND ra.overseas_warehouse_id=#{warehouseId}
AND UPPER(TRIM(COALESCE(NULLIF(ri.source_carton_mark,''),ri.carton_mark)))=UPPER(TRIM(c.carton_mark))
AND ra.status NOT IN ('CANCELLED','REJECTED'))
ORDER BY cartonMark
""")
java.util.List<SourceCarton> sourceCartons(@Param("customerId") String customerId,
@@ -3,11 +3,30 @@ package com.baiyue.lastleg.transfer.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
/** 使用提交单据的原箱快照表达业务占用,不提前扣减实物库存。 */
@Mapper
public interface TransferSourceReservationMapper {
/** 在出库事务中按原箱身份更新来源箱,保留箱内件数和清点时间。 */
@Update("""
UPDATE ll_warehouse_transfer_carton original
JOIN ll_warehouse_transfer_order origin_order
ON origin_order.id=original.transfer_order_id AND origin_order.customer_id=original.customer_id
JOIN ll_warehouse_transfer_order outgoing
ON outgoing.customer_id=origin_order.customer_id AND outgoing.inbound_code=origin_order.inbound_code
AND outgoing.operating_warehouse_id=origin_order.operating_warehouse_id
JOIN ll_warehouse_transfer_carton selected
ON selected.customer_id=outgoing.customer_id AND selected.transfer_order_id=outgoing.id
AND COALESCE(NULLIF(selected.customer_carton_mark,''),selected.carton_mark)=original.carton_mark
SET original.status='SHIPPED',original.updated_at=CURRENT_TIMESTAMP
WHERE outgoing.customer_id=#{customerId} AND outgoing.id=#{transferOrderId}
AND outgoing.transfer_no<>outgoing.inbound_code AND origin_order.transfer_no=origin_order.inbound_code
AND original.status='COUNTED'
""")
int shipSelectedCartons(@Param("customerId") String customerId, @Param("transferOrderId") Long transferOrderId);
/** 按同一入仓快照串行提交,防止不同草稿同时抢占原箱。 */
@Select("SELECT id FROM ll_inbound_order WHERE customer_id=#{customerId} AND id=#{inboundOrderId} FOR UPDATE")
Long lockInbound(@Param("customerId") String customerId, @Param("inboundOrderId") Long inboundOrderId);
@@ -21,6 +21,11 @@ public class TransferInventorySourceRepository {
return reservations.occupiedMarks(customerId, warehouseId, sourceCode);
}
/** 按当前转运实际选择的箱唛记录来源箱出库,不按箱数截断历史箱集合。 */
public int shipSelectedCartons(String customerId, Long transferOrderId) {
return reservations.shipSelectedCartons(customerId, transferOrderId);
}
/** 读取当前来源单未被有效换标申请使用的箱级快照。 */
public List<InboundSnapshotMapper.SourceCarton> sourceCartons(String customerId, String warehouseId, String inboundCode) {
return snapshots.sourceCartons(customerId, warehouseId, inboundCode);
@@ -1063,8 +1063,13 @@ public class WarehouseTransferService {
boolean hasSkuReceiptDifference(List<WarehouseTransferCarton> cartons, List<WarehouseTransferItem> items) {
Map<String, Long> expected = new java.util.HashMap<>();
Map<String, Long> received = new java.util.HashMap<>();
Set<String> unknown = new java.util.HashSet<>();
Map<Long, WarehouseTransferItem> byId = items.stream().collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity()));
for (var item : items) expected.merge(item.getSkuCode(), item.getDeclaredQuantity() == null ? 0L : item.getDeclaredQuantity(), Long::sum);
// 同SKU任一申报缺失时整体不可比较,不能把未知件数视作零制造差异。
for (var item : items) {
if (item.getDeclaredQuantity() == null) unknown.add(item.getSkuCode());
else expected.merge(item.getSkuCode(), item.getDeclaredQuantity(), Long::sum);
}
for (var carton : cartons) {
var contents = cartonContents(carton);
if (contents.isEmpty()) {
@@ -1075,8 +1080,10 @@ public class WarehouseTransferService {
for (var line : contents) received.merge(line.skuCode(), line.quantity(), Long::sum);
}
}
return expected.entrySet().stream().anyMatch(entry -> !entry.getValue().equals(received.getOrDefault(entry.getKey(), 0L)))
|| received.entrySet().stream().anyMatch(entry -> !entry.getValue().equals(expected.getOrDefault(entry.getKey(), 0L)));
return expected.entrySet().stream().anyMatch(entry -> !unknown.contains(entry.getKey())
&& !entry.getValue().equals(received.getOrDefault(entry.getKey(), 0L)))
|| received.entrySet().stream().anyMatch(entry -> !unknown.contains(entry.getKey())
&& !entry.getValue().equals(expected.getOrDefault(entry.getKey(), 0L)));
}
private List<WarehouseTransferCarton> requireCountedCartons(WarehouseTransferOrder transfer) {
@@ -1520,6 +1527,8 @@ public class WarehouseTransferService {
allocation.setStatus("SHIPPED"); allocation.setActivePositionKey(null);
allocation.setShippedAt(now); allocation.setUpdatedAt(now); inventoryAllocations.updateById(allocation);
}
// 同一事务记录所选原箱已出库,当前库存候选据此排除;签收快照仍完整保留。
inventorySources.shipSelectedCartons(customerId, transfer.getId());
transfer.setStatus(OUTBOUNDED); transfer.setOutboundIdempotencyKey(key);
transfer.setOutboundRequestHash(requestHash); transfer.setOutboundAt(now); transfer.setUpdatedAt(now);
transfers.updateById(transfer);
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.workflow;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.List;
/** 服务令牌保护的来源事实接口。 */
@RestController
@RequestMapping("/internal/lastleg/v1/order-workflow-facts")
public class InternalOrderWorkflowFactController {
private final InternalServiceTokenGuard guard;
private final OrderWorkflowFactService service;
/** 注入服务身份守卫。 */
public InternalOrderWorkflowFactController(InternalServiceTokenGuard guard,OrderWorkflowFactService service) { this.guard=guard;this.service=service; }
/** 身份验证后批量查询,不暴露公网。 */
@PostMapping
public ApiResponse<List<OrderWorkflowFacts.Fact>> read(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@Valid @RequestBody OrderWorkflowFacts.Request request) {
guard.verify(token);return ApiResponse.success(service.read(request));
}
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.workflow;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 以不可变来源明细核对出库,取消不再遮挡待排车。 */
@Mapper
public interface OrderWorkflowFactMapper {
/** 精确核对客户及来源;字符串参数沿用列排序规则,避免连接默认排序规则冲突。 */
@Select("""
<script>
SELECT o.customer_id tenant_id,o.source_inbound_id inbound_id,
EXISTS(SELECT 1 FROM ll_outbound_pallet_item i JOIN ll_outbound_order b ON b.id=i.outbound_order_id AND b.customer_id=i.customer_id
WHERE i.customer_id=o.customer_id AND i.source_order_id=o.id AND b.status NOT IN ('CANCELLED','CANCELED')) outbound_exists,
EXISTS(SELECT 1 FROM ll_outbound_pallet_item i JOIN ll_outbound_order b ON b.id=i.outbound_order_id AND b.customer_id=i.customer_id
WHERE i.customer_id=o.customer_id AND i.source_order_id=o.id AND b.status NOT IN ('CANCELLED','CANCELED','OUTBOUNDED')) pending_outbound,
EXISTS(SELECT 1 FROM ll_outbound_pallet_item i JOIN ll_outbound_order b ON b.id=i.outbound_order_id AND b.customer_id=i.customer_id
JOIN ll_delivery_order d ON d.outbound_order_id=b.id AND d.customer_id=b.customer_id
WHERE i.customer_id=o.customer_id AND i.source_order_id=o.id AND b.status='OUTBOUNDED' AND d.status='DELIVERING' AND d.dispatched_at IS NOT NULL
AND (d.destination_warehouse_code=i.destination_warehouse_code OR d.destination_warehouse_id=i.destination_warehouse_id
OR (d.destination_type='PRIVATE' AND EXISTS(SELECT 1 FROM ll_dispatch_item di
WHERE di.id=i.dispatch_item_id AND di.customer_id=i.customer_id AND di.dispatch_order_id=b.dispatch_order_id
AND di.destination_type='PRIVATE' AND TRIM(di.delivery_address)=TRIM(d.delivery_address)
AND COALESCE(TRIM(di.postal_code),'')=COALESCE(TRIM(d.postal_code),''))))) delivering,
EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.customer_id=o.customer_id AND p.source_order_id=o.id AND p.status IN ('COMPLETED','PENDING_RECONCILIATION')) unloaded
FROM ll_order o WHERE o.source_region='FIRSTLEG' AND o.order_type NOT IN ('RELABEL_APPLICATION','WAREHOUSE_TRANSFER')
AND <foreach collection='sources' item='s' open='(' separator=' OR ' close=')'>
(o.customer_id=#{s.tenantKey} AND o.source_inbound_id=#{s.inboundKey})
</foreach>
</script>
""")
List<OrderWorkflowFacts.Fact> read(@Param("sources") List<OrderWorkflowFacts.Source> sources);
}
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.workflow;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 尾程作业事实持久化入口。 */
@Repository
public class OrderWorkflowFactRepository {
private final OrderWorkflowFactMapper mapper;
/** 注入事实SQL提供者。 */
public OrderWorkflowFactRepository(OrderWorkflowFactMapper mapper) { this.mapper=mapper; }
/** 空来源不能扩大查询。 */
public List<OrderWorkflowFacts.Fact> read(List<OrderWorkflowFacts.Source> sources) { return sources.isEmpty()?List.of():mapper.read(sources); }
}
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.workflow;
import org.springframework.stereotype.Service;
import java.util.List;
/** 只读提供国内订单列表所需的真实作业事实。 */
@Service
public class OrderWorkflowFactService {
private final OrderWorkflowFactRepository repository;
/** 注入领域仓储。 */
public OrderWorkflowFactService(OrderWorkflowFactRepository repository) { this.repository=repository; }
/** 批量读取精确来源事实。 */
public List<OrderWorkflowFacts.Fact> read(OrderWorkflowFacts.Request request) { return repository.read(request.sources()); }
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.workflow;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;
/** 按客户和来源入仓单查询独立作业事实。 */
public final class OrderWorkflowFacts {
/** 仅作为内部契约命名空间,不允许实例化。 */
private OrderWorkflowFacts() {}
public record Source(@Positive long tenantId,@Positive long inboundId) {
/** 字符型客户键以字符串绑定,避免隐式数字比较丢失长整型精度。 */
public String tenantKey() { return Long.toString(tenantId); }
/** 来源入库键保持原值,不通过数据库CAST引入连接排序规则。 */
public String inboundKey() { return Long.toString(inboundId); }
}
public record Request(@NotEmpty @Size(max=500) List<@NotNull @Valid Source> sources) {}
public record Fact(long tenantId,long inboundId,boolean outboundExists,boolean pendingOutbound,boolean delivering,boolean unloaded) {}
}