提交
This commit is contained in:
+7
-5
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -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(),
|
||||
|
||||
+8
@@ -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) {
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
+8
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -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());
|
||||
}
|
||||
|
||||
+6
-5
@@ -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);
|
||||
|
||||
+17
@@ -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();
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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();
|
||||
|
||||
+7
-1
@@ -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())) {
|
||||
|
||||
+17
-14
@@ -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");
|
||||
|
||||
+4
-2
@@ -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
@@ -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," +
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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()));
|
||||
|
||||
+9
@@ -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(
|
||||
|
||||
+10
@@ -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")
|
||||
|
||||
+1
-24
@@ -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());
|
||||
|
||||
+3
-2
@@ -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();
|
||||
|
||||
+9
-1
@@ -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) { }
|
||||
}
|
||||
|
||||
|
||||
+22
@@ -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,
|
||||
|
||||
+19
@@ -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);
|
||||
|
||||
+5
@@ -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);
|
||||
|
||||
+12
-3
@@ -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);
|
||||
|
||||
+20
@@ -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));
|
||||
}
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
+12
@@ -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); }
|
||||
}
|
||||
+12
@@ -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) {}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
openapi: 3.0.3
|
||||
info: {title: 尾程订单作业事实内部查询, version: 1.0.0}
|
||||
paths:
|
||||
/internal/lastleg/v1/order-workflow-facts:
|
||||
post:
|
||||
summary: 批量查询精确客户和来源入仓单的作业事实
|
||||
description: 仅内部服务令牌。最多500个来源,禁止公网访问。无写入。有效出库包含实出库,pendingOutbound仅未取消且未出库。卸柜含真实差异卸柜完成。
|
||||
parameters:
|
||||
- {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [sources]
|
||||
properties:
|
||||
sources:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 500
|
||||
items:
|
||||
type: object
|
||||
required: [tenantId, inboundId]
|
||||
properties:
|
||||
tenantId: {type: integer, format: int64, minimum: 1}
|
||||
inboundId: {type: integer, format: int64, minimum: 1}
|
||||
responses:
|
||||
'200':
|
||||
description: 标准ApiResponse;不存在的来源不返回行,调用方须检查来源完整性
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string, example: '200'}
|
||||
message: {type: string}
|
||||
data:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/Fact'}
|
||||
components:
|
||||
schemas:
|
||||
Fact:
|
||||
type: object
|
||||
required: [tenantId, inboundId, outboundExists, pendingOutbound, delivering, unloaded]
|
||||
properties:
|
||||
tenantId: {type: string}
|
||||
inboundId: {type: string}
|
||||
outboundExists: {type: boolean}
|
||||
pendingOutbound: {type: boolean}
|
||||
delivering: {type: boolean}
|
||||
unloaded: {type: boolean}
|
||||
@@ -4979,7 +4979,7 @@ components:
|
||||
purchaseWarehouseId: { type: integer, format: int64, minimum: 1, nullable: true }
|
||||
ProcurementCreate:
|
||||
type: object
|
||||
required: [purchaseCategory, productName, purchaseQuantity, budgetUnitPrice, currency, receivingAddress, contactName, contactPhone, idempotencyKey]
|
||||
required: [productName, purchaseQuantity, budgetUnitPrice, currency, receivingAddress, contactName, contactPhone, idempotencyKey]
|
||||
properties:
|
||||
customerName: { type: string, nullable: true, maxLength: 255 }
|
||||
purchaseCategory: { type: string, maxLength: 128 }
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.lastleg.delivery;
|
||||
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
|
||||
import com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class OutboundQuantityRepositoryTest {
|
||||
/** 箱唛事实优先于历史件数,同箱多 SKU、多箱一行和无箱唛库存均须正确计数。 */
|
||||
@Test void countsUniqueMarksWithoutDependingOnHistoricPieceMetadata() {
|
||||
var mapper = mock(OutboundPalletItemMapper.class);
|
||||
var repository = new OutboundQuantityRepository();
|
||||
ReflectionTestUtils.setField(repository, "pallets", mapper);
|
||||
var outbound = new OutboundOrder();
|
||||
outbound.setId(1L); outbound.setCustomerId("C-1"); outbound.setOutboundType("DISCARD_OUTBOUND");
|
||||
outbound.setPackageCount(99);
|
||||
var a = new OutboundPalletItem(); a.setCartonMarks("BOX-1, BOX-2"); a.setPackageCount(5);
|
||||
var b = new OutboundPalletItem(); b.setCartonMarks("BOX-2,BOX-1"); b.setPackageCount(8);
|
||||
var unmarked = new OutboundPalletItem(); unmarked.setPackageCount(3);
|
||||
when(mapper.findByOutbound("C-1", 1L)).thenReturn(List.of(a, b, unmarked));
|
||||
assertEquals(5, repository.boxes(outbound));
|
||||
assertEquals(99, outbound.getPackageCount());
|
||||
outbound.setOutboundType("RETURN_OUTBOUND");
|
||||
assertEquals(99, repository.boxes(outbound));
|
||||
verify(mapper, times(1)).findByOutbound("C-1", 1L);
|
||||
verifyNoMoreInteractions(mapper);
|
||||
}
|
||||
}
|
||||
+24
@@ -133,6 +133,9 @@ class DeliveryTransferServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new DeliveryTransferService();
|
||||
var quantities = new com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository();
|
||||
ReflectionTestUtils.setField(quantities, "pallets", outboundPallets);
|
||||
ReflectionTestUtils.setField(service, "outboundQuantities", quantities);
|
||||
ReflectionTestUtils.setField(service, "deliveryExecution", new DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(outboundOrders, deliveryOrders)));
|
||||
ReflectionTestUtils.setField(service, "execution", execution);
|
||||
ReflectionTestUtils.setField(service,"cancellations",new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository(
|
||||
@@ -491,6 +494,27 @@ class DeliveryTransferServiceTest {
|
||||
assertEquals("DISCARD_OUTBOUND".equals(type) ? null : "LX-REAL-001", detail.outbound().lxCodes());
|
||||
}
|
||||
|
||||
/** 历史废弃单一箱内的多种 SKU 件数不能累加成出库箱数。 */
|
||||
@Test
|
||||
void discardListAndDetailCountPhysicalCartonsInsteadOfHistoricPieces() {
|
||||
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "discard-301");
|
||||
outbound.setOutboundType("DISCARD_OUTBOUND"); outbound.setPackageCount(5);
|
||||
var a = pallet(501L, "P-1", null, null, "RT-1", 2);
|
||||
var b = pallet(502L, "P-1", null, null, "RT-1", 3);
|
||||
a.setReturnSkuQuantity(2L); b.setReturnSkuQuantity(3L);
|
||||
a.setCartonMarks("BOX-1"); b.setCartonMarks("BOX-1");
|
||||
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(a, b));
|
||||
when(outboundOrders.selectPage(any(Page.class), any())).thenReturn(new Page<OutboundOrder>(1, 10, 1).setRecords(List.of(outbound)));
|
||||
|
||||
assertEquals(1, service.outboundDetail("OUT-301").outbound().packageCount());
|
||||
var query = new DeliveryRequests.Query(1, 10, "OUT-301", null, null, null, null, null, null, null, null, null, null, "DISCARD_OUTBOUND");
|
||||
assertEquals(1, service.outboundPage(null, query).records().get(0).packageCount());
|
||||
assertEquals(5, outbound.getPackageCount());
|
||||
assertEquals(2L, a.getReturnSkuQuantity()); assertEquals(3L, b.getReturnSkuQuantity());
|
||||
verify(outboundOrders, never()).updateById(any(OutboundOrder.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchOutboundStillRequiresItsDeliveryDocument() {
|
||||
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301");
|
||||
|
||||
+5
@@ -57,6 +57,11 @@ class ReturnOutboundClosureTest {
|
||||
ReturnInventoryCarton carton;
|
||||
|
||||
@BeforeEach void setup() {
|
||||
// 取消入口先查询幂等记录,手动组装的测试服务也需要注入该仓储。
|
||||
ReflectionTestUtils.setField(service, "cancellations", new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository(
|
||||
outbounds, items, deliveries, tasks, mock(DeliveryOperationMapper.class), relabelApplications, cartons,
|
||||
mock(com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper.class),
|
||||
mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class), mock(com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper.class)));
|
||||
ReflectionTestUtils.setField(service, "outboundOrders", outbounds);
|
||||
ReflectionTestUtils.setField(service, "outboundPallets", items);
|
||||
ReflectionTestUtils.setField(service, "pdaTasks", tasks);
|
||||
|
||||
+2
@@ -54,6 +54,8 @@ class ReturnOutboundPdaPersistenceTest {
|
||||
+ (field.getName().equals("id") ? " PRIMARY KEY" : "")).collect(Collectors.joining(","));
|
||||
jdbc.execute("CREATE TABLE " + entity.getAnnotation(TableName.class).value() + " (" + columns + ")");
|
||||
}
|
||||
jdbc.execute("CREATE TABLE ll_cargo_discard_application(id BIGINT,customer_id VARCHAR(2000),overseas_warehouse_id VARCHAR(2000),discard_no VARCHAR(2000))");
|
||||
jdbc.execute("CREATE TABLE ll_cargo_discard_item(id BIGINT,application_id BIGINT,customer_id VARCHAR(2000),return_carton_id BIGINT,sku_code VARCHAR(2000),product_name VARCHAR(2000),pda_status VARCHAR(2000),available_packages_snapshot BIGINT,actual_discard_packages BIGINT,pda_operator_id VARCHAR(2000),pda_completed_at TIMESTAMP)");
|
||||
var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true);
|
||||
for (Class<?> mapper : List.of(OutboundOrderMapper.class, OutboundPalletItemMapper.class,
|
||||
OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class, DeliveryOrderMapper.class, ReturnInventoryCartonMapper.class,
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.lastleg.discard;
|
||||
|
||||
import com.baiyue.lastleg.discard.model.CargoDiscardReturnMark;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** 废弃标签与签收实物标签一致,箱及托盘来源仍使用独立箱唛。 */
|
||||
class CargoDiscardReturnMarkTest {
|
||||
/** 兼容历史 RP 库存记录,不通过字符串替换猜测 PK 编码。 */
|
||||
@Test void historicalParcelUsesExactReceiptUnitCode() {
|
||||
var carton = new ReturnInventoryCarton(); carton.setSourceUnitType("PARCEL");
|
||||
carton.setCartonMark("RP-G1OTH7E5RRB6"); carton.setSourceUnitCode("PK-G1OTH7E5RRB5");
|
||||
assertEquals("PK-G1OTH7E5RRB5", CargoDiscardReturnMark.of(carton));
|
||||
carton.setSourceUnitType("BOX"); assertEquals("RP-G1OTH7E5RRB6", CargoDiscardReturnMark.of(carton));
|
||||
carton.setSourceUnitType("PARCEL"); carton.setSourceUnitCode(" ");
|
||||
assertEquals("RP-G1OTH7E5RRB6", CargoDiscardReturnMark.of(carton));
|
||||
assertNull(CargoDiscardReturnMark.of(null));
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -20,8 +20,7 @@ class ReturnDiscardMovementMapperTest {
|
||||
var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
var db = new JdbcTemplate(source);
|
||||
db.execute("CREATE TABLE ll_cargo_discard_application(id BIGINT,customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20),discard_no VARCHAR(30))");
|
||||
db.execute("CREATE TABLE ll_return_inventory_carton(id BIGINT,customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20))");
|
||||
db.execute("ALTER TABLE ll_return_inventory_carton ADD carton_mark VARCHAR(30), ADD source_batch_no VARCHAR(30), ADD created_at TIMESTAMP");
|
||||
db.execute("CREATE TABLE ll_return_inventory_carton(id BIGINT,customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20),carton_mark VARCHAR(30),source_batch_no VARCHAR(30),created_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_return_inventory_carton_item(carton_id BIGINT,sku_code VARCHAR(30),product_name_zh VARCHAR(30),quantity BIGINT)");
|
||||
db.execute("CREATE TABLE ll_return_receipt(id BIGINT,return_order_id BIGINT,receipt_no VARCHAR(30),signed_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_return_order(id BIGINT,customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20))");
|
||||
|
||||
+4
-3
@@ -193,18 +193,19 @@ class PdaCargoDiscardServiceTest {
|
||||
@Test void returnCartonUsesPieceUnitAndRequiresSelectedCartonEvenWhenBoundToPallet() {
|
||||
item.setSourceType("RETURN_CARTON"); item.setReturnCartonId(31L); item.setDiscardPackages(5);
|
||||
var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton();
|
||||
carton.setCustomerId("CUSTOMER"); carton.setPalletCode("PALLET-1"); carton.setCartonMark("RETURN-BOX-1");
|
||||
carton.setCustomerId("CUSTOMER"); carton.setPalletCode("PALLET-1"); carton.setCartonMark("RP-OLD");
|
||||
carton.setSourceUnitType("PARCEL"); carton.setSourceUnitCode("PK-REAL");
|
||||
when(returnCartons.selectById(31L)).thenReturn(carton);
|
||||
when(applications.selectList(any())).thenReturn(List.of(application));
|
||||
var task = service.resolve(identity, "PALLET-1", "CD-1");
|
||||
assertEquals("CARTON", task.scanMode());
|
||||
assertEquals(List.of("RETURN-BOX-1"), task.cartonMarks());
|
||||
assertEquals(List.of("PK-REAL"), task.cartonMarks());
|
||||
assertEquals(5, task.skus().get(0).plannedPackages());
|
||||
assertEquals("件", task.skus().get(0).quantityUnit());
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(5)));
|
||||
verifyNoInteractions(adjustments);
|
||||
var confirm = new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 5)),
|
||||
List.of(101L), List.of("RETURN-BOX-1"), false);
|
||||
List.of(101L), List.of("PK-REAL"), false);
|
||||
service.confirm(identity, "CD-1", "PALLET-1", confirm);
|
||||
assertEquals(1, application.getTotalPackages());
|
||||
assertEquals(new BigDecimal("5.00"), application.getTotalFee());
|
||||
|
||||
+3
-2
@@ -94,9 +94,10 @@ class CargoDiscardOutboundServiceTest {
|
||||
@Test void returnSourceKeepsRealCartonMarkWithoutInventingPalletNumber() {
|
||||
first.setReturnCartonId(31L); first.setSourceType("RETURN_CARTON");
|
||||
var carton = new ReturnInventoryCarton(); carton.setId(31L); carton.setCustomerId("C1");
|
||||
carton.setOverseasWarehouseId("99"); carton.setCartonMark("BOX-31");
|
||||
carton.setOverseasWarehouseId("99"); carton.setCartonMark("RP-OLD");
|
||||
carton.setSourceUnitType("PARCEL"); carton.setSourceUnitCode("PK-REAL");
|
||||
when(returnCartons.selectById(31L)).thenReturn(carton);
|
||||
service.create(application, "OP"); assertNull(snapshots.get(0).getPalletNo()); assertEquals("BOX-31", snapshots.get(0).getCartonMarks());
|
||||
service.create(application, "OP"); assertNull(snapshots.get(0).getPalletNo()); assertEquals("PK-REAL", snapshots.get(0).getCartonMarks());
|
||||
assertEquals(3L, snapshots.get(0).getReturnSkuQuantity());
|
||||
}
|
||||
@Test void invalidSourcePreventsAnyOutboundInsert() {
|
||||
|
||||
+20
@@ -47,6 +47,26 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CargoDiscardServiceTest {
|
||||
/** 历史包裹候选必须沿用签收 PK 码,不能展示内部 RP 码。 */
|
||||
@Test
|
||||
void parcelCandidatesAndDetailsUseReceiptLabel() {
|
||||
var carton = new ReturnInventoryCarton(); carton.setId(901L); carton.setCustomerId("100");
|
||||
carton.setOverseasWarehouseId("99"); carton.setSourceReturnNo("TT-BYWL-261019180631");
|
||||
carton.setSourceUnitType("PARCEL"); carton.setSourceUnitCode("PK-G1OTH7E5RRB5");
|
||||
carton.setCartonMark("RP-G1OTH7E5RRB6"); carton.setStatus("AVAILABLE");
|
||||
var sku = new ReturnInventoryCartonItem(); sku.setSkuCode("SKU"); sku.setQuantity(6L);
|
||||
when(returnCartons.findAvailable("100", null)).thenReturn(List.of(carton));
|
||||
when(returnCartons.selectById(901L)).thenReturn(carton);
|
||||
when(returnCartonItems.listCarton(901L)).thenReturn(List.of(sku));
|
||||
var candidates = service.candidates("100", "PK-G1OTH7E5RRB5");
|
||||
assertEquals(1, candidates.size()); assertEquals("PK-G1OTH7E5RRB5", candidates.get(0).cartonMark());
|
||||
assertEquals("PK-G1OTH7E5RRB5", candidates.get(0).cartons().get(0).cartonMark());
|
||||
storedApplication.set(pendingApplication());
|
||||
var line = item(); line.setSourceType("RETURN_CARTON"); line.setReturnCartonId(901L); storedItems.add(line);
|
||||
var detail = service.detail("100", "FH-1");
|
||||
assertEquals(List.of("PK-G1OTH7E5RRB5"), detail.items().get(0).cartonMarks());
|
||||
}
|
||||
|
||||
@Test
|
||||
void customerConfirmationClosesPdaCompletedApplicationOnceWithoutAnotherDeduction() {
|
||||
CargoDiscardApplication application = new CargoDiscardApplication();
|
||||
|
||||
+8
-4
@@ -112,20 +112,24 @@ class CargoDiscardStockServiceTest {
|
||||
verifyNoInteractions(commands, items, links);
|
||||
}
|
||||
|
||||
@Test void singlePositionTransferBoxesReleaseBackToTheirOriginalExpectedStatus() {
|
||||
@Test void receivedTransferBoxesAreSelectableAndReleaseBackToCountedStatus() {
|
||||
position.setAvailablePackages(2L); balance.setAvailablePackages(2L);
|
||||
var first = carton(1L, "WT-1#2-1"); first.setSourceType("TRANSFER");
|
||||
var second = carton(2L, "WT-1#2-2"); second.setSourceType("TRANSFER");
|
||||
when(stock.genericCartons("C1", 42L)).thenReturn(List.of(first, second));
|
||||
when(stock.changeTransferCartonStatus("C1", 2L, "EXPECTED", "DISCARD_PENDING")).thenReturn(1);
|
||||
when(stock.genericPositions("C1", "WT")).thenReturn(List.of(position));
|
||||
var candidate = service.candidates("C1", "WT").get(0);
|
||||
assertTrue(candidate.cartonSelectionSupported());
|
||||
assertEquals(List.of("WT-1#2-1", "WT-1#2-2"), candidate.cartons().stream().map(box -> box.cartonMark()).toList());
|
||||
when(stock.changeTransferCartonStatus("C1", 2L, "COUNTED", "DISCARD_PENDING")).thenReturn(1);
|
||||
service.reserve("C1", "OP", application, new CargoDiscardRequests.Item(null, null, "SKU", 1, 42L, List.of(2L)), LocalDateTime.now());
|
||||
var capture = ArgumentCaptor.forClass(CargoDiscardCarton.class); verify(links).insert(capture.capture());
|
||||
assertEquals("TRANSFER", capture.getValue().getSourceType());
|
||||
when(links.listApplication("C1", 11L)).thenReturn(List.of(capture.getValue()));
|
||||
when(stock.changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "EXPECTED")).thenReturn(1);
|
||||
when(stock.changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "COUNTED")).thenReturn(1);
|
||||
var line = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(line.capture());
|
||||
service.release("C1", application, line.getValue());
|
||||
verify(stock).changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "EXPECTED");
|
||||
verify(stock).changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "COUNTED");
|
||||
verify(stock, never()).changeGenericCartonStatus(anyString(), anyLong(), anyString(), anyString());
|
||||
}
|
||||
|
||||
|
||||
+13
@@ -10,6 +10,19 @@ import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class ProcurementRequestBoundaryTest {
|
||||
@Test void purchaseCategoryMayBeOmittedOrBlankButCannotExceedLimit() throws Exception {
|
||||
var json = new ObjectMapper();
|
||||
try (var factory = jakarta.validation.Validation.buildDefaultValidatorFactory()) {
|
||||
var validator = factory.getValidator();
|
||||
for (String category : List.of("null", "\"\"", "\"sample\"")) {
|
||||
var request = json.readValue("{\"purchaseCategory\":" + category + "}", OverseasProcurementRequests.Create.class);
|
||||
assertThat(validator.validate(request)).noneMatch(v -> "purchaseCategory".equals(v.getPropertyPath().toString()));
|
||||
}
|
||||
var oversized = json.readValue("{\"purchaseCategory\":\"" + "x".repeat(129) + "\"}", OverseasProcurementRequests.Create.class);
|
||||
assertThat(validator.validate(oversized)).anyMatch(v -> "purchaseCategory".equals(v.getPropertyPath().toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void createRejectsMultiProductAndBatchExtensionsWithApplicationJacksonDefaults() {
|
||||
new org.springframework.boot.test.context.runner.ApplicationContextRunner()
|
||||
.withConfiguration(org.springframework.boot.autoconfigure.AutoConfigurations.of(
|
||||
|
||||
+1
-1
@@ -185,7 +185,7 @@ class ReturnInventoryServiceTest {
|
||||
var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setUnitType("PARCEL"); receipt.setUnitCode("PK-1");
|
||||
var source = service.createReceiptCarton(completedOrder(), receipt, null,
|
||||
List.of(new ReturnInventoryService.ReceiptLine("A", "商品A", 3)));
|
||||
assertThat(source.getCartonMark()).startsWith("RP-").hasSizeLessThanOrEqualTo(16);
|
||||
assertThat(source.getCartonMark()).isEqualTo("PK-1");
|
||||
assertThat(source.getSourceUnitType()).isEqualTo("PARCEL");
|
||||
verifyNoInteractions(numbers);
|
||||
assertThat(source.getLabelStatus()).isEqualTo("CONFIRMED");
|
||||
|
||||
+13
@@ -56,6 +56,16 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ReturnReceiptServiceTest {
|
||||
@Test void rejectsUnprintablePhysicalCodesBeforeReceiptWrites() {
|
||||
for (String code : List.of("中文包裹", "PK-中文")) {
|
||||
var request = new PdaReturnReceiptRequests.Confirm("RT-1", null, "RETURN_NO", null,
|
||||
List.of(new PdaReturnReceiptRequests.Item("SKU1", 1)), "INVALID-CODE", "PARCEL", code,
|
||||
java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, null, true);
|
||||
assertThat(assertThrows(ResponseStatusException.class, () -> service.confirm(identity, request)).getReason())
|
||||
.contains("实物编码无法生成条码");
|
||||
}
|
||||
verifyNoInteractions(orders, receipts, orderItems, receiptItems, inventory);
|
||||
}
|
||||
@Test void countedCargoReplacesForecastTotalsAndRepeatedSyncUpdatesTheSameDetail() {
|
||||
var details = org.mockito.Mockito.mock(com.baiyue.lastleg.mapper.LastlegOrderDetailMapper.class);
|
||||
ReflectionTestUtils.setField(service, "lastlegOrderDetails", details);
|
||||
@@ -268,6 +278,8 @@ class ReturnReceiptServiceTest {
|
||||
assertEquals("RL-MULTI", order.getRelabelNo());
|
||||
var application = ArgumentCaptor.forClass(RelabelApplication.class);
|
||||
verify(relabelApplications).insert(application.capture());
|
||||
assertEquals("", application.getValue().getContactName());
|
||||
assertEquals("", application.getValue().getContactPhone());
|
||||
assertEquals(firstQuantity + secondQuantity, application.getValue().getNotifiedPackageCount().longValue());
|
||||
assertEquals(2, application.getValue().getItemCount());
|
||||
var lines = ArgumentCaptor.forClass(RelabelItem.class);
|
||||
@@ -637,3 +649,4 @@ class ReturnReceiptServiceTest {
|
||||
"BOX", palletCode, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, 1L, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-3
@@ -36,6 +36,16 @@ class TransferInventorySourceTest {
|
||||
db.execute("CREATE TABLE ll_relabel_application(id BIGINT,customer_id VARCHAR,overseas_warehouse_id VARCHAR,status VARCHAR,customer_confirmed_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_relabel_item(application_id BIGINT,customer_id VARCHAR,source_inbound_code VARCHAR,source_carton_mark VARCHAR,carton_mark VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_relabel_pda_unit_fact(item_id BIGINT,result VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_dropship_carton(id BIGINT,customer_id VARCHAR,order_id BIGINT,profile_id BIGINT,carton_mark VARCHAR,sku_code VARCHAR,product_quantity BIGINT,status VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_dropship_carton_sku(customer_id VARCHAR,carton_id BIGINT,sku_code VARCHAR,product_quantity BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dropship_order_profile(id BIGINT,customer_id VARCHAR,inventory_position_id BIGINT,status VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR,balance_id BIGINT,available_packages BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR,available_packages BIGINT)");
|
||||
db.update("INSERT INTO ll_dropship_order_profile VALUES(101,'A',201,'ACTIVE')");
|
||||
db.update("INSERT INTO ll_inventory_position VALUES(201,'A',301,2)");
|
||||
db.update("INSERT INTO ll_inventory_balance VALUES(301,'A',2)");
|
||||
db.update("INSERT INTO ll_dropship_carton VALUES(401,'A',1,101,'DROPSHIP','SKU',5,'AVAILABLE'),(402,'A',1,101,'SHIPPED','SKU',5,'OUTBOUNDED')");
|
||||
db.update("INSERT INTO ll_dropship_carton_sku VALUES('A',401,'SKU',3),('A',401,'SKU-B',2)");
|
||||
db.update("INSERT INTO ll_order VALUES(1,'A','IN','WH'),(2,'B','IN','WH'),(3,'A','IN','OTHER'),(4,'A','OTHER','WH')");
|
||||
db.update("INSERT INTO ll_inbound_plan_carton VALUES('A',1,'FIRST','S','ONT8','Amazon'),('A',1,'RELABELED','S','ONT8','Amazon'),('B',2,'FOREIGN','S','ONT8','Amazon'),('A',3,'WRONG-WH','S','ONT8','Amazon'),('A',4,'WRONG-IN','S','ONT8','Amazon'),('B',1,'FORGED','S','ONT8','Amazon')");
|
||||
db.update("INSERT INTO ll_relabel_application VALUES(51,'A','WH','COMPLETED',CURRENT_TIMESTAMP()),(52,'A','WH','PROCESSING',NULL),(53,'A','WH','CANCELLED',NULL),(54,'A','WH','REJECTED',NULL),(55,'B','WH','COMPLETED',CURRENT_TIMESTAMP()),(56,'A','OTHER','COMPLETED',CURRENT_TIMESTAMP())");
|
||||
@@ -47,9 +57,13 @@ class TransferInventorySourceTest {
|
||||
var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds)); config.addMapper(InboundSnapshotMapper.class);
|
||||
try(var session=new SqlSessionFactoryBuilder().build(config).openSession()) {
|
||||
var result=session.getMapper(InboundSnapshotMapper.class).sourceCartons("A","WH","IN");
|
||||
assertEquals(List.of("FIRST", "MEASURED"),result.stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).toList());
|
||||
assertEquals("ONT8",result.get(0).destinationWarehouseCode());
|
||||
assertNull(result.get(0).quantity()); assertEquals(20L,result.get(1).quantity());
|
||||
assertEquals(List.of("DROPSHIP", "FIRST", "MEASURED"),result.stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).toList());
|
||||
assertEquals(5L,result.get(0).quantity()); assertTrue(result.get(0).skuItemsJson().contains("SKU"));
|
||||
assertTrue(result.get(0).skuItemsJson().contains("SKU-B"), "混装箱内所有 SKU 均须保留,箱子只返回一次");
|
||||
assertEquals("ONT8",result.get(1).destinationWarehouseCode());
|
||||
assertNull(result.get(1).quantity()); assertEquals(20L,result.get(2).quantity());
|
||||
db.update("UPDATE ll_inventory_position SET available_packages=0"); session.clearCache();
|
||||
assertEquals(List.of("FIRST", "MEASURED"),session.getMapper(InboundSnapshotMapper.class).sourceCartons("A","WH","IN").stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).toList());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -175,6 +175,18 @@ class WarehouseTransferCartonCountTest {
|
||||
ia.setDeclaredQuantity(4L);
|
||||
assertTrue(service.hasSkuReceiptDifference(List.of(a),List.of(ia,ib)));
|
||||
}
|
||||
/** 新箱必须先补客户预报,PDA不能绕过预报生成另一套箱号。 */
|
||||
@Test void missingDeclarationIsUnknownButKnownSkuDifferenceStillRequiresReason() {
|
||||
var unknown = new WarehouseTransferItem(); unknown.setId(10L); unknown.setSkuCode("A");
|
||||
var known = new WarehouseTransferItem(); known.setId(11L); known.setSkuCode("B"); known.setDeclaredQuantity(7L);
|
||||
a.setSkuItemsJson("[{\"transferItemId\":10,\"skuCode\":\"A\",\"quantity\":4},{\"transferItemId\":11,\"skuCode\":\"B\",\"quantity\":7}]");
|
||||
assertFalse(service.hasSkuReceiptDifference(List.of(a), List.of(unknown, known)));
|
||||
known.setDeclaredQuantity(8L);
|
||||
assertTrue(service.hasSkuReceiptDifference(List.of(a), List.of(unknown, known)));
|
||||
known.setDeclaredQuantity(7L); unknown.setDeclaredQuantity(0L);
|
||||
assertTrue(service.hasSkuReceiptDifference(List.of(a), List.of(unknown, known)));
|
||||
}
|
||||
|
||||
/** 新箱必须先补客户预报,PDA不能绕过预报生成另一套箱号。 */
|
||||
@Test void extraReceiptRequiresForecastCartonFirst() {
|
||||
var extra = new WarehouseTransferRequests.CountCarton(null,11L,200L,new BigDecimal("10"),new BigDecimal("20"),new BigDecimal("30"),BigDecimal.ONE,List.of(new WarehouseTransferRequests.CountSku("B",4)));
|
||||
|
||||
+2
@@ -521,6 +521,8 @@ class WarehouseTransferServiceTest {
|
||||
transfer.setInboundCode("IN-1");
|
||||
transfer.setNeedRelabel(true);
|
||||
transfer.setPackageCount(1);
|
||||
var selected = new WarehouseTransferCarton();
|
||||
selected.setId(51L); selected.setCustomerCartonMark("CURRENT-BOX");
|
||||
when(inboundSnapshots.sourceCartons("TENANT-A", "WH-1", "IN-1"))
|
||||
.thenReturn(List.of(new InboundSnapshotMapper.SourceCarton("CURRENT-BOX", "SKU-1", null, null, null, null, null)));
|
||||
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.lastleg.workflow;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
/** 通过真实Mapper验证出库取消和客户隔离,使用隔离内存库。 */
|
||||
class OrderWorkflowFactMapperTest {
|
||||
/** 有效出库、取消、实出库和差异卸柜均由持久化事实决定。 */
|
||||
@Test void cancellationAndCustomerAttribution() throws Exception {
|
||||
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:workflow-"+java.util.UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
var db=new JdbcTemplate(ds);
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(30),source_inbound_id VARCHAR(30),source_region VARCHAR(20),order_type VARCHAR(30))");
|
||||
db.execute("CREATE TABLE ll_outbound_pallet_item(outbound_order_id BIGINT,customer_id VARCHAR(30),source_order_id BIGINT,destination_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30),dispatch_item_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_outbound_order(id BIGINT,customer_id VARCHAR(30),status VARCHAR(30),dispatch_order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_delivery_order(outbound_order_id BIGINT,customer_id VARCHAR(30),status VARCHAR(30),dispatched_at TIMESTAMP,destination_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30),destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20))");
|
||||
db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(30),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20))");
|
||||
db.execute("CREATE TABLE ll_inbound_plan(customer_id VARCHAR(30),source_order_id BIGINT,status VARCHAR(40))");
|
||||
db.update("INSERT INTO ll_order VALUES(1,'11','101','FIRSTLEG','FBA'),(2,'22','101','FIRSTLEG','FBA')");
|
||||
db.update("INSERT INTO ll_outbound_order VALUES(9,'11','WAIT_OUTBOUND',50),(10,'22','WAIT_OUTBOUND',51)");
|
||||
db.update("INSERT INTO ll_outbound_pallet_item VALUES(9,'11',1,'FBA1','3',60),(10,'22',2,'FBA1','3',61)");
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(OrderWorkflowFactMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
||||
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(OrderWorkflowFactMapper.class);
|
||||
var sources=List.of(new OrderWorkflowFacts.Source(11,101));
|
||||
assertThat(mapper.read(sources)).singleElement().satisfies(f->{assertThat(f.tenantId()).isEqualTo(11);assertThat(f.outboundExists()).isTrue();assertThat(f.pendingOutbound()).isTrue();});
|
||||
db.update("UPDATE ll_outbound_order SET status='CANCELLED' WHERE id=9");
|
||||
assertThat(mapper.read(sources)).singleElement().satisfies(f->{assertThat(f.outboundExists()).isFalse();assertThat(f.pendingOutbound()).isFalse();});
|
||||
db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED' WHERE id=9");
|
||||
db.update("INSERT INTO ll_delivery_order VALUES(9,'11','DELIVERING',CURRENT_TIMESTAMP,'FBA1','3','FBA',NULL,NULL)");
|
||||
db.update("INSERT INTO ll_inbound_plan VALUES('11',1,'PENDING_RECONCILIATION')");
|
||||
assertThat(mapper.read(sources)).singleElement().satisfies(f->{assertThat(f.outboundExists()).isTrue();assertThat(f.pendingOutbound()).isFalse();assertThat(f.delivering()).isTrue();assertThat(f.unloaded()).isTrue();});
|
||||
db.update("UPDATE ll_delivery_order SET destination_type='PRIVATE',destination_warehouse_code=NULL,destination_warehouse_id=NULL,delivery_address='Private A',postal_code='10001'");
|
||||
db.update("INSERT INTO ll_dispatch_item VALUES(60,'11',50,'PRIVATE','Private A','10001')");
|
||||
assertThat(mapper.read(sources)).singleElement().satisfies(f->assertThat(f.delivering()).isTrue());
|
||||
db.update("UPDATE ll_dispatch_item SET customer_id='22'");
|
||||
assertThat(mapper.read(sources)).singleElement().satisfies(f->assertThat(f.delivering()).isFalse());
|
||||
assertThat(mapper.read(List.of(new OrderWorkflowFacts.Source(33,101)))).isEmpty();
|
||||
// 相邻雪花ID超过浮点安全范围,必须按字符串精确匹配客户和来源。
|
||||
db.update("INSERT INTO ll_order VALUES(3,'2092814835845664769','2092814880129130499','FIRSTLEG','FBA'),(4,'2092814835845664768','2092814880129130499','FIRSTLEG','FBA'),(5,'2092814835845664769','2092814880129130498','FIRSTLEG','FBA')");
|
||||
assertThat(mapper.read(List.of(new OrderWorkflowFacts.Source(2092814835845664769L,2092814880129130499L))))
|
||||
.singleElement().satisfies(f->{assertThat(f.tenantId()).isEqualTo(2092814835845664769L);assertThat(f.inboundId()).isEqualTo(2092814880129130499L);});
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.lastleg.workflow;
|
||||
|
||||
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 内部事实入口不能被缺失或错误服务身份读取。 */
|
||||
class OrderWorkflowSecurityTest {
|
||||
/** 无效服务令牌在仓储查询前被拒绝。 */
|
||||
@Test void rejectsInvalidServiceIdentityBeforeReadingFacts() {
|
||||
var service=mock(OrderWorkflowFactService.class);
|
||||
var controller=new InternalOrderWorkflowFactController(new InternalServiceTokenGuard("test-service-token"),service);
|
||||
var request=new OrderWorkflowFacts.Request(List.of(new OrderWorkflowFacts.Source(11,101)));
|
||||
assertThatThrownBy(()->controller.read(null,request)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
assertThatThrownBy(()->controller.read("wrong-token",request)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
verifyNoInteractions(service);
|
||||
when(service.read(request)).thenReturn(List.of());
|
||||
assertThat(controller.read("test-service-token",request).successful()).isTrue();
|
||||
verify(service).read(request);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user