This commit is contained in:
2026-08-20 18:06:07 +08:00
parent c9a05b108b
commit a38396ce3f
26 changed files with 532 additions and 21 deletions
@@ -1,12 +1,16 @@
package com.baiyue.lastleg.inboundplan.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest;
import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
import io.swagger.v3.oas.annotations.Operation;
@@ -26,6 +30,17 @@ public class InboundPlanController {
@Resource private InboundPlanService service;
@Resource private InboundFbaAppointmentService appointmentService;
/** 按入仓计划明细分页查询可预约的入库订单行。 */
@GetMapping
@PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:list')")
@Operation(summary = "分页查询入库订单")
public ApiResponse<PageResult<InboundPlanListResponse>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId,
@Valid InboundPlanRequests.Query query) {
return ApiResponse.success(service.page(scope(user, customerId), query));
}
@PostMapping("/convert")
@PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:create')")
/** 将入仓单转换为入仓计划。 */
@@ -78,6 +93,17 @@ public class InboundPlanController {
return ApiResponse.success(appointmentService.detail(scope(user, customerId), appointmentNo));
}
@PutMapping("/fba-appointments/{appointmentNo}")
@PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:appointment')")
@Operation(summary = "编辑 FBA 预约")
public ApiResponse<FbaAppointmentResponse> updateFbaAppointment(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String appointmentNo,
@Valid @RequestBody UpdateFbaAppointmentRequest request) {
return ApiResponse.success(appointmentService.update(scope(user, request.customerId()),
appointmentNo, request));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
@@ -13,6 +13,7 @@ public record BatchFbaAppointmentRequest(
@NotEmpty @Size(max = 500) List<@NotNull Long> lineIds,
@NotBlank @Size(max = 64) String appointmentNo,
@NotNull LocalDateTime appointmentAt,
@NotNull LocalDateTime expectedLoadingAt,
@NotBlank @Size(max = 32) String appointmentStatus,
@Size(max = 1000) String appointmentRemark
) {
@@ -8,6 +8,7 @@ public record FbaAppointmentResponse(
String customerId,
String appointmentNo,
LocalDateTime appointmentAt,
LocalDateTime expectedLoadingAt,
String appointmentStatus,
String appointmentRemark,
String fbaWarehouseCode,
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.inboundplan.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public record InboundPlanListResponse(
Long lineId,
Long planId,
String planNo,
String customerId,
Long orderId,
String inboundCode,
String orderType,
String warehouseId,
String warehouseCode,
String warehouseName,
String fbaWarehouseCode,
String customerName,
Integer packageCount,
BigDecimal volumeCbm,
Integer palletCount,
String status,
String appointmentNo,
String appointmentStatus,
LocalDateTime appointmentAt,
LocalDateTime expectedLoadingAt,
String dispatchStatus,
LocalDateTime createdAt
) {
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.inboundplan.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;
import java.time.LocalDateTime;
public final class InboundPlanRequests {
private InboundPlanRequests() {
}
public record Query(
@Min(1) long page,
@Min(1) @Max(200) long size,
String planNo,
String inboundCode,
String status,
String warehouse,
String fbaWarehouseCode,
@Pattern(regexp = "UNAPPOINTED|PENDING|SUCCESS|FAILED|CANCELLED") String appointmentStatus,
@Pattern(regexp = "UNSCHEDULED|SCHEDULED") String dispatchStatus,
LocalDateTime createdFrom,
LocalDateTime createdTo
) {
public Query {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.inboundplan.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
public record UpdateFbaAppointmentRequest(
String customerId,
@NotNull LocalDateTime appointmentAt,
@NotNull LocalDateTime expectedLoadingAt,
@NotBlank @Size(max = 32) String appointmentStatus,
@Size(max = 1000) String appointmentRemark
) {
}
@@ -15,6 +15,7 @@ public class InboundFbaAppointment {
private String customerId;
private String appointmentNo;
private LocalDateTime appointmentAt;
private LocalDateTime expectedLoadingAt;
private String appointmentStatus;
private String appointmentRemark;
private String fbaWarehouseCode;
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface InboundPlanQueryMapper {
String FROM_AND_FILTER = " FROM ll_inbound_plan_line l" +
" JOIN ll_inbound_plan p ON p.id=l.plan_id AND p.customer_id=#{customerId}" +
" JOIN ll_order o ON o.id=l.order_id AND o.customer_id=#{customerId}" +
" LEFT JOIN ll_inbound_fba_appointment_item ai ON ai.line_id=l.id" +
" AND ai.id=(SELECT ai2.id FROM ll_inbound_fba_appointment_item ai2" +
" JOIN ll_inbound_fba_appointment a2 ON a2.id=ai2.appointment_id" +
" WHERE ai2.line_id=l.id AND a2.customer_id=#{customerId}" +
" ORDER BY a2.created_at DESC,ai2.id DESC LIMIT 1)" +
" LEFT JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id AND a.customer_id=#{customerId}" +
" WHERE 1=1" +
"<if test='q.planNo != null and q.planNo != \"\"'> AND p.plan_no LIKE CONCAT('%',#{q.planNo},'%')</if>" +
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
"<if test='q.status != null and q.status != \"\"'> AND p.status=#{q.status}</if>" +
"<if test='q.warehouse != null and q.warehouse != \"\"'> AND (l.destination_warehouse_id LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_code LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_name LIKE CONCAT('%',#{q.warehouse},'%'))</if>" +
"<if test='q.fbaWarehouseCode != null and q.fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code LIKE CONCAT('%',#{q.fbaWarehouseCode},'%')</if>" +
"<choose><when test='q.appointmentStatus == \"UNAPPOINTED\"'> AND ai.id IS NULL</when>" +
"<when test='q.appointmentStatus != null and q.appointmentStatus != \"\"'> AND a.appointment_status=#{q.appointmentStatus}</when></choose>" +
"<choose><when test='q.dispatchStatus == \"SCHEDULED\"'> AND EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.customer_id=#{customerId} AND di.order_id=l.order_id)</when>" +
"<when test='q.dispatchStatus == \"UNSCHEDULED\"'> AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.customer_id=#{customerId} AND di.order_id=l.order_id)</when></choose>" +
"<if test='q.createdFrom != null'> AND p.created_at &gt;=#{q.createdFrom}</if>" +
"<if test='q.createdTo != null'> AND p.created_at &lt;=#{q.createdTo}</if>";
@Select("<script>SELECT l.id lineId,p.id planId,p.plan_no planNo,p.customer_id customerId," +
"l.order_id orderId,l.inbound_code inboundCode,o.order_type orderType," +
"l.destination_warehouse_id warehouseId,l.destination_warehouse_code warehouseCode," +
"l.destination_warehouse_name warehouseName,l.fba_warehouse_code fbaWarehouseCode," +
"o.customer_name customerName,l.required_package_count packageCount,l.volume_cbm volumeCbm," +
"l.planned_pallet_count palletCount,p.status status,a.appointment_no appointmentNo," +
"a.appointment_status appointmentStatus,a.appointment_at appointmentAt," +
"a.expected_loading_at expectedLoadingAt," +
"CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.customer_id=#{customerId} AND di.order_id=l.order_id)" +
" THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END dispatchStatus,p.created_at createdAt" +
FROM_AND_FILTER + " ORDER BY p.created_at DESC,l.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<InboundPlanListResponse> rows(@Param("customerId") String customerId,
@Param("q") InboundPlanRequests.Query query,
@Param("offset") long offset,
@Param("limit") long limit);
@Select("<script>SELECT COUNT(*)" + FROM_AND_FILTER + "</script>")
long count(@Param("customerId") String customerId, @Param("q") InboundPlanRequests.Query query);
}
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper;
@@ -44,7 +45,7 @@ public class InboundFbaAppointmentService {
InboundFbaAppointment existing = find(customerId, appointmentNo);
if (existing != null) {
return verifyReplay(existing, lineIds, request.appointmentAt(), status, remark);
return verifyReplay(existing, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark);
}
List<FbaAppointmentLineSnapshot> lines = queryMapper.lockLines(customerId, lineIds);
@@ -57,7 +58,7 @@ public class InboundFbaAppointmentService {
// The row locks serialize concurrent submissions selecting the same inbound lines.
existing = find(customerId, appointmentNo);
if (existing != null) {
return verifyReplay(existing, lineIds, request.appointmentAt(), status, remark);
return verifyReplay(existing, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark);
}
LocalDateTime now = LocalDateTime.now();
@@ -66,6 +67,7 @@ public class InboundFbaAppointmentService {
appointment.setCustomerId(customerId);
appointment.setAppointmentNo(appointmentNo);
appointment.setAppointmentAt(request.appointmentAt());
appointment.setExpectedLoadingAt(request.expectedLoadingAt());
appointment.setAppointmentStatus(status);
appointment.setAppointmentRemark(remark);
appointment.setFbaWarehouseCode(fbaWarehouseCode);
@@ -78,7 +80,7 @@ public class InboundFbaAppointmentService {
} catch (DuplicateKeyException exception) {
InboundFbaAppointment concurrent = find(customerId, appointmentNo);
if (concurrent != null) {
return verifyReplay(concurrent, lineIds, request.appointmentAt(), status, remark);
return verifyReplay(concurrent, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark);
}
throw exception;
}
@@ -108,12 +110,31 @@ public class InboundFbaAppointmentService {
return response(appointment, items(appointment.getId()));
}
@Transactional
public FbaAppointmentResponse update(String customerId, String appointmentNo,
UpdateFbaAppointmentRequest request) {
InboundFbaAppointment appointment = find(customerId,
requiredTrimmed(appointmentNo, "Appointment number is required"));
if (appointment == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "FBA appointment not found");
}
appointment.setAppointmentAt(request.appointmentAt());
appointment.setExpectedLoadingAt(request.expectedLoadingAt());
appointment.setAppointmentStatus(normalizeStatus(request.appointmentStatus()));
appointment.setAppointmentRemark(trimToNull(request.appointmentRemark()));
appointment.setUpdatedAt(LocalDateTime.now());
appointmentMapper.updateById(appointment);
return response(appointment, items(appointment.getId()));
}
private FbaAppointmentResponse verifyReplay(InboundFbaAppointment existing, List<Long> requestedLineIds,
LocalDateTime appointmentAt, String status, String remark) {
LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt,
String status, String remark) {
List<InboundFbaAppointmentItem> existingItems = items(existing.getId());
Set<Long> existingLineIds = existingItems.stream()
.map(InboundFbaAppointmentItem::getLineId).collect(java.util.stream.Collectors.toSet());
if (!Objects.equals(existing.getAppointmentAt(), appointmentAt)
|| !Objects.equals(existing.getExpectedLoadingAt(), expectedLoadingAt)
|| !Objects.equals(existing.getAppointmentStatus(), status)
|| !Objects.equals(existing.getAppointmentRemark(), remark)
|| existingLineIds.size() != requestedLineIds.size()
@@ -149,7 +170,8 @@ public class InboundFbaAppointmentService {
private FbaAppointmentResponse response(InboundFbaAppointment appointment,
List<InboundFbaAppointmentItem> items) {
return new FbaAppointmentResponse(appointment.getId(), appointment.getCustomerId(),
appointment.getAppointmentNo(), appointment.getAppointmentAt(), appointment.getAppointmentStatus(),
appointment.getAppointmentNo(), appointment.getAppointmentAt(), appointment.getExpectedLoadingAt(),
appointment.getAppointmentStatus(),
appointment.getAppointmentRemark(), appointment.getFbaWarehouseCode(), appointment.getSelectedCount(),
appointment.getCreatedBy(), appointment.getCreatedAt(), appointment.getUpdatedAt(),
items.stream().map(item -> new FbaAppointmentResponse.Item(item.getId(), item.getPlanId(),
@@ -2,6 +2,9 @@ package com.baiyue.lastleg.inboundplan.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
@@ -13,6 +16,7 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanOrderMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanQueryMapper;
import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
@@ -35,6 +39,16 @@ public class InboundPlanService {
@Resource private InboundPlanPalletMapper palletMapper;
@Resource private InboundPlanPalletAllocationMapper allocationMapper;
@Resource private InboundPlanOrderMapper orderMapper;
@Resource private InboundPlanQueryMapper queryMapper;
@Transactional(readOnly = true)
public PageResult<InboundPlanListResponse> page(String customerId, InboundPlanRequests.Query query) {
long total = queryMapper.count(customerId, query);
List<InboundPlanListResponse> records = total == 0 ? List.of() : queryMapper.rows(
customerId, query, (query.page() - 1) * query.size(), query.size());
long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size();
return new PageResult<>(records, query.page(), query.size(), total, pages);
}
@Transactional
public InboundPlanResponse convert(String customerId, Long selectedOrderId) {
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.inventory.query.dto;
import java.time.LocalDateTime;
import java.util.List;
public record InventoryBalanceView(
Long id,
@@ -15,6 +16,10 @@ public record InventoryBalanceView(
Long inboundOrderId,
String inboundOrderNo,
String inboundCode,
List<String> skuCodes,
List<String> productNames,
List<Long> warehouseLocationIds,
List<String> warehouseLocationCodes,
long totalPackages,
long availablePackages,
long holdPackages,
@@ -13,6 +13,7 @@ public record InventoryLedgerView(
String palletNo,
String batchNo,
String warehouseLocationCode,
String skuCode,
String businessType,
String businessOrderNo,
String outboundOrderNo,
@@ -19,6 +19,10 @@ public final class InventoryQueryRequests {
String inboundOrderNo,
String inboundCode,
String businessOrderNo,
String skuCode,
String productName,
String batchNo,
String warehouseLocationCode,
String inventoryStatus
) {
public BalanceQuery {
@@ -42,6 +46,8 @@ public final class InventoryQueryRequests {
String outboundOrderNo,
String palletNo,
String batchNo,
String skuCode,
String warehouseLocationCode,
LocalDateTime occurredFrom,
LocalDateTime occurredTo
) {
@@ -5,6 +5,8 @@ import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
import com.baiyue.lastleg.inventory.query.model.InventoryAggregateProjection;
import com.baiyue.lastleg.inventory.query.model.InventoryLocationFact;
import com.baiyue.lastleg.inventory.query.model.InventorySkuFact;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@@ -19,6 +21,10 @@ public interface InventoryQueryMapper {
"<if test='q.inboundOrderNo != null and q.inboundOrderNo != \"\"'> AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')</if>" +
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=ll_inventory_balance.id AND l.customer_id=#{customerId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" +
"<if test='q.skuCode != null and q.skuCode != \"\"'> AND EXISTS (SELECT 1 FROM ll_order_detail d WHERE d.order_id=ll_inventory_balance.inbound_order_id AND d.sku_code LIKE CONCAT('%',#{q.skuCode},'%'))</if>" +
"<if test='q.productName != null and q.productName != \"\"'> AND EXISTS (SELECT 1 FROM ll_order_detail d WHERE d.order_id=ll_inventory_balance.inbound_order_id AND (d.product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR d.product_name_en LIKE CONCAT('%',#{q.productName},'%')))</if>" +
"<if test='q.batchNo != null and q.batchNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=#{customerId} AND p.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))</if>" +
"<if test='q.warehouseLocationCode != null and q.warehouseLocationCode != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=#{customerId} AND p.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))</if>" +
"<choose>" +
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND available_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages &gt; 0</when>" +
@@ -37,6 +43,10 @@ public interface InventoryQueryMapper {
"<if test='q.inboundOrderNo != null and q.inboundOrderNo != \"\"'> AND b.inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')</if>" +
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND b.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=b.id AND l.customer_id=#{customerId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" +
"<if test='q.skuCode != null and q.skuCode != \"\"'> AND EXISTS (SELECT 1 FROM ll_order_detail d WHERE d.order_id=b.inbound_order_id AND d.sku_code LIKE CONCAT('%',#{q.skuCode},'%'))</if>" +
"<if test='q.productName != null and q.productName != \"\"'> AND EXISTS (SELECT 1 FROM ll_order_detail d WHERE d.order_id=b.inbound_order_id AND (d.product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR d.product_name_en LIKE CONCAT('%',#{q.productName},'%')))</if>" +
"<if test='q.batchNo != null and q.batchNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=#{customerId} AND p2.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))</if>" +
"<if test='q.warehouseLocationCode != null and q.warehouseLocationCode != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=#{customerId} AND p2.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))</if>" +
"<choose>" +
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND b.available_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND b.hold_packages &gt; 0</when>" +
@@ -58,6 +68,22 @@ public interface InventoryQueryMapper {
long countBalances(@Param("customerId") String customerId,
@Param("q") InventoryQueryRequests.BalanceQuery query);
@Select("<script>SELECT DISTINCT b.id balance_id,d.sku_code," +
"COALESCE(NULLIF(d.product_name_zh,''),d.product_name_en) product_name " +
"FROM ll_inventory_balance b JOIN ll_order_detail d ON d.order_id=b.inbound_order_id " +
"WHERE b.customer_id=#{customerId} AND b.id IN " +
"<foreach collection='balanceIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"AND d.sku_code IS NOT NULL AND d.sku_code&lt;&gt;'' ORDER BY b.id,d.sku_code</script>")
List<InventorySkuFact> skuFacts(@Param("customerId") String customerId,
@Param("balanceIds") List<Long> balanceIds);
@Select("<script>SELECT DISTINCT balance_id,warehouse_location_id,warehouse_location_code " +
"FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id IN " +
"<foreach collection='balanceIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"ORDER BY balance_id,warehouse_location_code</script>")
List<InventoryLocationFact> locationFacts(@Param("customerId") String customerId,
@Param("balanceIds") List<Long> balanceIds);
@Select("<script>SELECT COUNT(*) dimension_count," +
"COALESCE(SUM(available_packages),0) available_packages," +
"COALESCE(SUM(hold_packages),0) hold_packages," +
@@ -103,6 +129,8 @@ public interface InventoryQueryMapper {
"<if test='q.outboundOrderNo != null and q.outboundOrderNo != \"\"'> AND outbound_order_no LIKE CONCAT('%',#{q.outboundOrderNo},'%')</if>" +
"<if test='q.palletNo != null and q.palletNo != \"\"'> AND pallet_no LIKE CONCAT('%',#{q.palletNo},'%')</if>" +
"<if test='q.batchNo != null and q.batchNo != \"\"'> AND batch_no LIKE CONCAT('%',#{q.batchNo},'%')</if>" +
"<if test='q.skuCode != null and q.skuCode != \"\"'> AND sku_code LIKE CONCAT('%',#{q.skuCode},'%')</if>" +
"<if test='q.warehouseLocationCode != null and q.warehouseLocationCode != \"\"'> AND warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%')</if>" +
"<if test='q.occurredFrom != null'> AND occurred_at &gt;=#{q.occurredFrom}</if>" +
"<if test='q.occurredTo != null'> AND occurred_at &lt;=#{q.occurredTo}</if>";
@@ -0,0 +1,10 @@
package com.baiyue.lastleg.inventory.query.model;
import lombok.Data;
@Data
public class InventoryLocationFact {
private Long balanceId;
private Long warehouseLocationId;
private String warehouseLocationCode;
}
@@ -0,0 +1,10 @@
package com.baiyue.lastleg.inventory.query.model;
import lombok.Data;
@Data
public class InventorySkuFact {
private Long balanceId;
private String skuCode;
private String productName;
}
@@ -11,6 +11,8 @@ import com.baiyue.lastleg.inventory.query.dto.InventoryPositionView;
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper;
import com.baiyue.lastleg.inventory.query.model.InventoryAggregateProjection;
import com.baiyue.lastleg.inventory.query.model.InventoryLocationFact;
import com.baiyue.lastleg.inventory.query.model.InventorySkuFact;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@@ -19,7 +21,9 @@ import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class InventoryQueryService {
@@ -51,8 +55,8 @@ public class InventoryQueryService {
public PageResult<InventoryBalanceView> balances(String customerId, InventoryQueryRequests.BalanceQuery query) {
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
long total = mapper.countBalances(customerId, query);
List<InventoryBalanceView> records = total == 0 ? List.of() : mapper.balances(
customerId, query, offset(query.page(), query.size()), query.size()).stream().map(this::balanceView).toList();
List<InventoryBalanceView> records = total == 0 ? List.of() : balanceViews(customerId,
mapper.balances(customerId, query, offset(query.page(), query.size()), query.size()));
return page(records, query.page(), query.size(), total);
}
@@ -78,12 +82,14 @@ public class InventoryQueryService {
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
List<String> rows = new ArrayList<>();
rows.add("customerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," +
"destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,totalPackages,availablePackages," +
"destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,skuCodes,productNames," +
"warehouseLocationCodes,totalPackages,availablePackages," +
"holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages");
mapper.balances(customerId, query, 0, EXPORT_LIMIT).stream().map(this::balanceView).forEach(v -> rows.add(
balanceViews(customerId, mapper.balances(customerId, query, 0, EXPORT_LIMIT)).forEach(v -> rows.add(
String.join(",", csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseCode()), csv(v.overseasWarehouseName()),
csv(v.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.inboundOrderId()),
csv(v.inboundOrderNo()), csv(v.inboundCode()),
csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(String.join("|", v.skuCodes())),
csv(String.join("|", v.productNames())), csv(String.join("|", v.warehouseLocationCodes())),
csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()),
csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()),
csv(v.procurementPackages()))));
@@ -105,7 +111,19 @@ public class InventoryQueryService {
return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8);
}
private InventoryBalanceView balanceView(InventoryBalance value) {
private List<InventoryBalanceView> balanceViews(String customerId, List<InventoryBalance> balances) {
if (balances.isEmpty()) return List.of();
List<Long> ids = balances.stream().map(InventoryBalance::getId).toList();
Map<Long, List<InventorySkuFact>> skus = mapper.skuFacts(customerId, ids).stream()
.collect(Collectors.groupingBy(InventorySkuFact::getBalanceId));
Map<Long, List<InventoryLocationFact>> locations = mapper.locationFacts(customerId, ids).stream()
.collect(Collectors.groupingBy(InventoryLocationFact::getBalanceId));
return balances.stream().map(value -> balanceView(value,
skus.getOrDefault(value.getId(), List.of()), locations.getOrDefault(value.getId(), List.of()))).toList();
}
private InventoryBalanceView balanceView(InventoryBalance value, List<InventorySkuFact> skus,
List<InventoryLocationFact> locations) {
long availablePackages = number(value.getAvailablePackages());
long holdPackages = number(value.getHoldPackages());
long allocatedPackages = number(value.getAllocatedPackages());
@@ -116,7 +134,12 @@ public class InventoryQueryService {
return new InventoryBalanceView(value.getId(), value.getCustomerId(), value.getCustomerName(), value.getOverseasWarehouseId(),
value.getOverseasWarehouseCode(), value.getOverseasWarehouseName(), value.getDestinationWarehouseId(),
value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(), value.getInboundOrderId(),
value.getInboundOrderNo(), value.getInboundCode(), availablePackages + holdPackages + allocatedPackages + pickedPackages
value.getInboundOrderNo(), value.getInboundCode(),
skus.stream().map(InventorySkuFact::getSkuCode).filter(InventoryQueryService::hasText).distinct().toList(),
skus.stream().map(InventorySkuFact::getProductName).filter(InventoryQueryService::hasText).distinct().toList(),
locations.stream().map(InventoryLocationFact::getWarehouseLocationId).filter(java.util.Objects::nonNull).distinct().toList(),
locations.stream().map(InventoryLocationFact::getWarehouseLocationCode).filter(InventoryQueryService::hasText).distinct().toList(),
availablePackages + holdPackages + allocatedPackages + pickedPackages
+ inTransitPackages + receivingPackages + procurementPackages,
availablePackages, holdPackages, allocatedPackages, pickedPackages, inTransitPackages,
receivingPackages, procurementPackages, value.getUpdatedAt());
@@ -141,7 +164,7 @@ public class InventoryQueryService {
return new InventoryLedgerView(value.getId(), value.getCustomerId(), value.getOverseasWarehouseId(),
value.getDestinationWarehouseCode(), value.getInboundOrderId(), value.getInboundOrderNo(), value.getInboundCode(),
value.getPalletNo(), value.getBatchNo(),
value.getWarehouseLocationCode(), value.getBusinessType(), value.getBusinessOrderNo(),
value.getWarehouseLocationCode(), value.getSkuCode(), value.getBusinessType(), value.getBusinessOrderNo(),
value.getOutboundOrderNo(), value.getStatusFrom(), value.getStatusTo(),
number(value.getBeforePackages()), number(value.getDeltaPackages()), number(value.getAfterPackages()),
value.getOperatorId(), value.getRemark(), value.getOccurredAt());
@@ -168,6 +191,10 @@ public class InventoryQueryService {
return value == null ? 0 : value;
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
private static <T> PageResult<T> page(List<T> records, long page, long size, long total) {
long pages = total == 0 ? 0 : (total + size - 1) / size;
return new PageResult<>(records, page, size, total, pages);
@@ -553,6 +553,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
appointment_no VARCHAR(64) NOT NULL COMMENT '预约单号',
appointment_at DATETIME(3) NOT NULL COMMENT '预约时间',
expected_loading_at DATETIME(3) NOT NULL COMMENT '预计装车时间',
appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '预约状态',
appointment_remark VARCHAR(1000) NULL COMMENT '预约备注',
fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码',
@@ -23,10 +23,12 @@ class LastlegPlatformPermissionContractTest {
assertPermission(LastlegOrderController.class, "export", "platform:lastleg:order:export");
assertPermission(LastlegOrderController.class, "transferOne", "platform:lastleg:order:transfer");
assertPermission(InboundPlanController.class, "page", "platform:lastleg:inbound-plan:list");
assertPermission(InboundPlanController.class, "detail", "platform:lastleg:inbound-plan:list");
assertPermission(InboundPlanController.class, "fbaAppointmentDetail", "platform:lastleg:inbound-plan:list");
assertPermission(InboundPlanController.class, "convert", "platform:lastleg:inbound-plan:create");
assertPermission(InboundPlanController.class, "batchFbaAppointment", "platform:lastleg:inbound-plan:appointment");
assertPermission(InboundPlanController.class, "updateFbaAppointment", "platform:lastleg:inbound-plan:appointment");
assertPermission(RelabelApplicationController.class, "page", "platform:lastleg:relabel:list");
assertPermission(RelabelApplicationController.class, "detail", "platform:lastleg:relabel:list");
@@ -10,11 +10,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class InboundPlanSchemaContractTest {
@Test
void planIsUniqueByTenantAndActualContainerNumber() throws IOException {
void planIsUniqueByTenantAndSourceOrderWhileContainerMayHaveMultiplePlans() throws IOException {
try (var stream = getClass().getClassLoader().getResourceAsStream("db/lastleg.sql")) {
assertTrue(stream != null, "lastleg schema must be available");
String schema = new String(stream.readAllBytes(), StandardCharsets.UTF_8).toLowerCase();
assertTrue(schema.contains("unique key uk_ll_inbound_plan_container (customer_id, container_no)"));
assertTrue(schema.contains("unique key uk_ll_inbound_plan_source_order (customer_id, source_order_id)"));
assertTrue(schema.contains("key idx_ll_inbound_plan_container (customer_id, container_no)"));
assertTrue(schema.contains("default 'pending_assignment'"));
assertTrue(schema.contains("required_package_count"));
assertTrue(schema.contains("planned_package_count"));
@@ -23,6 +24,7 @@ class InboundPlanSchemaContractTest {
assertTrue(schema.contains("create table if not exists ll_inbound_pda_operation"));
assertTrue(schema.contains("create table if not exists ll_inbound_pallet_merge"));
assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment"));
assertTrue(schema.contains("expected_loading_at datetime(3) not null comment '预计装车时间'"));
assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (customer_id, appointment_no)"));
assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment_item"));
assertTrue(schema.contains("fba_warehouse_code"));
@@ -0,0 +1,68 @@
package com.baiyue.lastleg.inboundplan.controller;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Set;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundPlanControllerTest {
@Mock InboundPlanService service;
@Mock InboundFbaAppointmentService appointmentService;
private InboundPlanController controller;
@BeforeEach
void setUp() {
controller = new InboundPlanController();
ReflectionTestUtils.setField(controller, "service", service);
ReflectionTestUtils.setField(controller, "appointmentService", appointmentService);
}
@Test
void platformPageRequiresAndPassesExplicitCustomerId() {
InboundPlanRequests.Query query = new InboundPlanRequests.Query(1, 20, null, null, null,
null, null, null, null, null, null);
when(service.page("100", query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
controller.page(platform(), "100", query);
verify(service).page("100", query);
assertThatThrownBy(() -> controller.page(platform(), "", query))
.isInstanceOf(ResponseStatusException.class)
.satisfies(error -> org.assertj.core.api.Assertions.assertThat(
((ResponseStatusException) error).getStatusCode().value()).isEqualTo(400));
}
@Test
void platformAppointmentUpdateUsesExplicitCustomerScope() {
UpdateFbaAppointmentRequest request = new UpdateFbaAppointmentRequest("100",
LocalDateTime.of(2026, 8, 20, 10, 30), LocalDateTime.of(2026, 8, 21, 8, 0),
"PENDING", "dock");
controller.updateFbaAppointment(platform(), "APT-1", request);
verify(appointmentService).update("100", "APT-1", request);
}
private static AuthenticatedUser platform() {
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB",
Set.of("platform:lastleg:inbound-plan:list"));
}
}
@@ -1,12 +1,14 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper;
import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -35,6 +37,7 @@ class InboundFbaAppointmentServiceTest {
private InboundFbaAppointmentService service;
private final LocalDateTime appointmentAt = LocalDateTime.of(2026, 8, 20, 10, 30);
private final LocalDateTime expectedLoadingAt = LocalDateTime.of(2026, 8, 21, 8, 0);
@BeforeEach
void setUp() {
@@ -57,6 +60,7 @@ class InboundFbaAppointmentServiceTest {
assertEquals("APT-1", result.appointmentNo());
assertEquals("ONT8", result.fbaWarehouseCode());
assertEquals(expectedLoadingAt, result.expectedLoadingAt());
assertEquals(2, result.selectedCount());
verify(appointmentMapper).insert(any(InboundFbaAppointment.class));
verify(itemMapper, org.mockito.Mockito.times(2)).insert(any(InboundFbaAppointmentItem.class));
@@ -114,8 +118,51 @@ class InboundFbaAppointmentServiceTest {
() -> service.batchCreate("100", "7", request(List.of(12L), "APT-1")));
}
@Test
void sameAppointmentNumberWithDifferentExpectedLoadingTimeConflicts() {
when(appointmentMapper.selectOne(any())).thenReturn(appointment(99L, "APT-1", "ONT8"));
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
item.setLineId(11L);
when(itemMapper.selectList(any())).thenReturn(List.of(item));
BatchFbaAppointmentRequest changed = new BatchFbaAppointmentRequest("100", List.of(11L), "APT-1",
appointmentAt, expectedLoadingAt.plusHours(1), "SUCCESS", "dock");
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.batchCreate("100", "7", changed));
assertEquals(409, exception.getStatusCode().value());
}
@Test
void stringSnowflakeLineIdsDeserializeWithoutPrecisionLoss() throws Exception {
String json = "{\"customerId\":\"100\",\"lineIds\":[\"9007199254740993\"],"
+ "\"appointmentNo\":\"APT-1\",\"appointmentAt\":\"2026-08-20T10:30:00\","
+ "\"expectedLoadingAt\":\"2026-08-21T08:00:00\",\"appointmentStatus\":\"PENDING\"}";
BatchFbaAppointmentRequest request = new ObjectMapper().findAndRegisterModules()
.readValue(json, BatchFbaAppointmentRequest.class);
assertEquals(List.of(9007199254740993L), request.lineIds());
assertEquals(expectedLoadingAt, request.expectedLoadingAt());
}
@Test
void updatesAppointmentDetailsWithoutReplacingSelectedLines() {
InboundFbaAppointment existing = appointment(99L, "APT-1", "ONT8");
when(appointmentMapper.selectOne(any())).thenReturn(existing);
LocalDateTime loadingAt = expectedLoadingAt.plusHours(2);
var result = service.update("100", "APT-1", new UpdateFbaAppointmentRequest(
"100", appointmentAt.plusDays(1), loadingAt, "PENDING", "updated"));
assertEquals(loadingAt, result.expectedLoadingAt());
assertEquals("PENDING", result.appointmentStatus());
verify(appointmentMapper).updateById(existing);
verify(itemMapper, never()).insert(any(InboundFbaAppointmentItem.class));
}
private BatchFbaAppointmentRequest request(List<Long> lineIds, String number) {
return new BatchFbaAppointmentRequest("100", lineIds, number, appointmentAt, "SUCCESS", "dock");
return new BatchFbaAppointmentRequest("100", lineIds, number, appointmentAt, expectedLoadingAt,
"SUCCESS", "dock");
}
private FbaAppointmentLineSnapshot line(Long id, String inboundCode, String fba) {
@@ -128,6 +175,7 @@ class InboundFbaAppointmentServiceTest {
value.setCustomerId("100");
value.setAppointmentNo(number);
value.setAppointmentAt(appointmentAt);
value.setExpectedLoadingAt(expectedLoadingAt);
value.setAppointmentStatus("SUCCESS");
value.setAppointmentRemark("dock");
value.setFbaWarehouseCode(fba);
@@ -0,0 +1,83 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanQueryMapper;
import jakarta.validation.Validation;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundPlanPaginationTest {
@Mock InboundPlanQueryMapper queryMapper;
private InboundPlanService service;
@BeforeEach
void setUp() {
service = new InboundPlanService();
ReflectionTestUtils.setField(service, "queryMapper", queryMapper);
}
@Test
void queryDefaultsPageAndSizeAndRejectsOversizedPages() {
InboundPlanRequests.Query defaults = query(0, 0);
assertThat(defaults.page()).isEqualTo(1);
assertThat(defaults.size()).isEqualTo(20);
try (var validatorFactory = Validation.buildDefaultValidatorFactory()) {
var violations = validatorFactory.getValidator().validate(query(1, 201));
assertThat(violations).extracting(value -> value.getPropertyPath().toString()).contains("size");
}
}
@Test
void pagesAtLineGranularityAndPassesTenantAndAllFiltersToDatabase() {
InboundPlanRequests.Query query = new InboundPlanRequests.Query(2, 10, "LIP", "IN-1",
"PENDING_ASSIGNMENT", "ONT", "ONT8", "UNAPPOINTED", "UNSCHEDULED",
LocalDateTime.of(2026, 8, 1, 0, 0), LocalDateTime.of(2026, 8, 31, 23, 59));
InboundPlanListResponse row = new InboundPlanListResponse(9007199254740993L, 2L, "LIP2", "100",
3L, "IN-1", "FIRST_LEG", "W1", "ONT", "安大略仓", "ONT8", "客户A", 10,
BigDecimal.ONE, 1, "PENDING_ASSIGNMENT", null, null, null, null, "UNSCHEDULED",
LocalDateTime.of(2026, 8, 20, 10, 0));
when(queryMapper.count("100", query)).thenReturn(11L);
when(queryMapper.rows("100", query, 10, 10)).thenReturn(List.of(row));
var result = service.page("100", query);
assertThat(result.records()).containsExactly(row);
assertThat(result.records().get(0).lineId()).isEqualTo(9007199254740993L);
assertThat(result.records().get(0).appointmentStatus()).isNull();
assertThat(result.pages()).isEqualTo(2);
verify(queryMapper).count("100", query);
verify(queryMapper).rows("100", query, 10, 10);
}
@Test
void mapperSqlProjectsLatestAppointmentAndTenantScopedDispatchState() throws Exception {
String sql = String.join("", InboundPlanQueryMapper.class
.getMethod("rows", String.class, InboundPlanRequests.Query.class, long.class, long.class)
.getAnnotation(org.apache.ibatis.annotations.Select.class).value()) + InboundPlanQueryMapper.FROM_AND_FILTER;
assertThat(sql).contains("a2.customer_id=#{customerId}", "ORDER BY a2.created_at DESC,ai2.id DESC LIMIT 1");
assertThat(sql).contains("di.customer_id=#{customerId} AND di.order_id=l.order_id");
assertThat(sql).contains("q.planNo", "q.inboundCode", "q.status", "q.warehouse",
"q.fbaWarehouseCode", "q.appointmentStatus", "q.dispatchStatus", "q.createdFrom", "q.createdTo");
assertThat(sql).contains("LIMIT #{limit} OFFSET #{offset}");
}
private static InboundPlanRequests.Query query(long page, long size) {
return new InboundPlanRequests.Query(page, size, null, null, null, null, null,
null, null, null, null);
}
}
@@ -18,7 +18,7 @@ class InventoryQueryControllerTest {
private final InventoryQueryService service = mock(InventoryQueryService.class);
private final InventoryQueryController controller = controller();
private final InventoryQueryRequests.BalanceQuery query = new InventoryQueryRequests.BalanceQuery(
1, 20, null, null, null, null, null, null, null, null);
1, 20, null, null, null, null, null, null, null, null, null, null, null, null);
@Test
void merchantCannotOverrideCustomerScope() {
@@ -6,6 +6,8 @@ import com.baiyue.lastleg.inventory.query.dto.InventoryDashboardResponse;
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper;
import com.baiyue.lastleg.inventory.query.model.InventoryAggregateProjection;
import com.baiyue.lastleg.inventory.query.model.InventoryLocationFact;
import com.baiyue.lastleg.inventory.query.model.InventorySkuFact;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
@@ -58,9 +60,21 @@ class InventoryQueryServiceTest {
balance.setCustomerId("100");
when(mapper.countBalances("100", query)).thenReturn(1L);
when(mapper.balances("100", query, 0, 20)).thenReturn(List.of(balance));
InventorySkuFact sku = new InventorySkuFact();
sku.setBalanceId(1L);
sku.setSkuCode("SKU-1");
sku.setProductName("测试产品");
InventoryLocationFact location = new InventoryLocationFact();
location.setBalanceId(1L);
location.setWarehouseLocationCode(null);
when(mapper.skuFacts("100", List.of(1L))).thenReturn(List.of(sku));
when(mapper.locationFacts("100", List.of(1L))).thenReturn(List.of(location));
service.balances("100", query);
var result = service.balances("100", query);
assertEquals(List.of("SKU-1"), result.records().get(0).skuCodes());
assertEquals(List.of("测试产品"), result.records().get(0).productNames());
assertEquals(List.of(), result.records().get(0).warehouseLocationCodes());
verify(mapper).countBalances("100", query);
verify(mapper).balances("100", query, 0, 20);
}
@@ -106,6 +120,7 @@ class InventoryQueryServiceTest {
void reversedLedgerDateRangeIsRejected() {
InventoryQueryRequests.LedgerQuery query = new InventoryQueryRequests.LedgerQuery(
1, 20, null, null, null, null, null, null, null, null, null,
null, null,
java.time.LocalDateTime.of(2026, 8, 18, 0, 0),
java.time.LocalDateTime.of(2026, 8, 17, 0, 0));
@@ -120,12 +135,12 @@ class InventoryQueryServiceTest {
String csv = new String(service.exportBalances("100", query), StandardCharsets.UTF_8);
assertTrue(csv.contains("inboundOrderNo,inboundCode,totalPackages"));
assertFalse(csv.contains("skuCode"));
assertTrue(csv.contains("inboundOrderNo,inboundCode,skuCodes"));
assertFalse(csv.contains("Quantity"));
}
private static InventoryQueryRequests.BalanceQuery balanceQuery(String status) {
return new InventoryQueryRequests.BalanceQuery(1, 20, null, null, null, null, null, null, null, status);
return new InventoryQueryRequests.BalanceQuery(1, 20, null, null, null, null, null, null, null,
null, null, null, null, status);
}
}
+10
View File
@@ -3644,3 +3644,13 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE lastleg_unload_relay_029 FROM @ddl; EXECUTE lastleg_unload_relay_029; DEALLOCATE PREPARE lastleg_unload_relay_029;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_at DATETIME(3) NULL COMMENT ''当前投递租约获取时间'' AFTER locked_by'));
PREPARE lastleg_unload_relay_029 FROM @ddl; EXECUTE lastleg_unload_relay_029; DEALLOCATE PREPARE lastleg_unload_relay_029;
-- ============================================================================
-- 2026-08-20 / 030
-- FBA预约预计装车时间。
-- ============================================================================
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='expected_loading_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_fba_appointment ADD COLUMN expected_loading_at DATETIME(3) NULL COMMENT ''预计装车时间'' AFTER appointment_at'));
PREPARE lastleg_fba_appointment_030 FROM @ddl; EXECUTE lastleg_fba_appointment_030; DEALLOCATE PREPARE lastleg_fba_appointment_030;
UPDATE ll_inbound_fba_appointment SET expected_loading_at=appointment_at WHERE expected_loading_at IS NULL;
ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN expected_loading_at DATETIME(3) NOT NULL COMMENT '预计装车时间';