This commit is contained in:
2026-08-31 14:39:40 +08:00
parent a4429f95b7
commit a4189a0074
25 changed files with 345 additions and 64 deletions
@@ -1055,6 +1055,22 @@ CREATE TABLE IF NOT EXISTS ll_order_operation_log (
) ENGINE=InnoDB COMMENT='尾程订单操作记录'; ) ENGINE=InnoDB COMMENT='尾程订单操作记录';
USE baiyue_lastleg; USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='version'
), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''入库计划乐观锁版本'' AFTER updated_at'));
PREPARE ll_inbound_plan_version_ddl FROM @ddl;
EXECUTE ll_inbound_plan_version_ddl;
DEALLOCATE PREPARE ll_inbound_plan_version_ddl;
SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_batch_id'
), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag'));
PREPARE ll_order_merge_batch_ddl FROM @ddl;
EXECUTE ll_order_merge_batch_ddl;
DEALLOCATE PREPARE ll_order_merge_batch_ddl;
CREATE TABLE IF NOT EXISTS ll_security_event_inbox ( CREATE TABLE IF NOT EXISTS ll_security_event_inbox (
event_id VARCHAR(64) NOT NULL COMMENT '跨区安全事件ID', event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型', event_id VARCHAR(64) NOT NULL COMMENT '跨区安全事件ID', event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型',
payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256哈希', payload_json JSON NOT NULL COMMENT '冻结事件载荷JSON', payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256哈希', payload_json JSON NOT NULL COMMENT '冻结事件载荷JSON',
@@ -32,7 +32,6 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -119,19 +118,29 @@ public class LastlegOrderController {
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@PutMapping("/api/platform/v1/last-leg/orders/pallet-merge-required/batch")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "Mark a selected tail-leg order group for PDA pallet merge")
public ApiResponse<Void> setPalletMergeBatch(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid @RequestBody LastlegOrderRequests.SetPalletMergeBatch request) {
orderService.setPalletMergeBatch(platformScope(user, customerId), request, operatorName(user));
return ApiResponse.success(null);
}
@GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv") @GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EXPORT + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EXPORT + "')")
@Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EXPORT) @Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EXPORT)
public ResponseEntity<StreamingResponseBody> export( public ResponseEntity<byte[]> export(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@Valid LastlegOrderRequests.Query query) { @Valid LastlegOrderRequests.Query query) {
byte[] body = orderService.exportCsv(platformScope(user, customerId), query); byte[] body = orderService.exportCsv(platformScope(user, customerId), query);
StreamingResponseBody stream = output -> { output.write(body); output.flush(); };
return ResponseEntity.ok() return ResponseEntity.ok()
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv") .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv")
.body(stream); .body(body);
} }
@PutMapping("/api/platform/v1/last-leg/orders/{id}/destination") @PutMapping("/api/platform/v1/last-leg/orders/{id}/destination")
@@ -7,7 +7,8 @@ public final class DispatchPdaResponses {
public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status, public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status,
List<Item> items) {} List<Item> items) {}
public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode, public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, int scheduledPackages, String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel,
int scheduledPackages,
long pickedPackages, List<Position> positions) {} long pickedPackages, List<Position> positions) {}
public record Position(Long id, Long balanceId, long availablePackages, long allocatedPackages, public record Position(Long id, Long balanceId, long availablePackages, long allocatedPackages,
long pickedPackages) {} long pickedPackages) {}
@@ -225,7 +225,8 @@ public class DispatchPdaWorkflowService {
List<InventoryPosition> palletPositions = item.getWarehousePalletId() == null ? List.of() List<InventoryPosition> palletPositions = item.getWarehousePalletId() == null ? List.of()
: positions.listPallet(customerId, item.getWarehousePalletId()); : positions.listPallet(customerId, item.getWarehousePalletId());
values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(), values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(),
item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), item.getPackageCount(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
warehouseRouteLabel(item.getFbaWarehouseCode(), item.getDestinationWarehouseCode()), item.getPackageCount(),
picks.totalItem(customerId, task.getId(), item.getId()), palletPositions.stream().map(p -> picks.totalItem(customerId, task.getId(), item.getId()), palletPositions.stream().map(p ->
new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages()), new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages()),
n(p.getAllocatedPackages()), n(p.getPickedPackages()))).toList())); n(p.getAllocatedPackages()), n(p.getPickedPackages()))).toList()));
@@ -268,6 +269,12 @@ public class DispatchPdaWorkflowService {
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException(e); } } private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException(e); } }
private static long n(Long value) { return value == null ? 0 : value; } private static long n(Long value) { return value == null ? 0 : value; }
private static long n(Integer value) { return value == null ? 0 : value; } private static long n(Integer value) { return value == null ? 0 : value; }
static String warehouseRouteLabel(String originalFbaWarehouseCode, String destinationWarehouseCode) {
String original = trim(originalFbaWarehouseCode);
String destination = trim(destinationWarehouseCode);
return original != null && destination != null && !original.equalsIgnoreCase(destination)
? original + " -> " + destination : null;
}
private static String trim(String value) { return value == null || value.trim().isEmpty() ? null : value.trim(); } private static String trim(String value) { return value == null || value.trim().isEmpty() ? null : value.trim(); }
private static String required(String value, String message) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); return result; } private static String required(String value, String message) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); return result; }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
@@ -18,8 +18,8 @@ public final class LastlegOrderRequests {
} }
public record Query( public record Query(
@Min(1) long page, @Min(1) Long page,
@Min(1) @Max(200) long size, @Min(1) @Max(200) Long size,
String orderNo, String orderNo,
String orderType, String orderType,
String inboundCode, String inboundCode,
@@ -35,8 +35,8 @@ public final class LastlegOrderRequests {
LocalDateTime createdTo LocalDateTime createdTo
) { ) {
public Query { public Query {
if (page == 0) page = 1; if (page == null || page == 0) page = 1L;
if (size == 0) size = 20; if (size == null || size == 0) size = 20L;
} }
} }
@@ -79,4 +79,10 @@ public final class LastlegOrderRequests {
@NotNull Boolean palletMergeRequired @NotNull Boolean palletMergeRequired
) { ) {
} }
public record SetPalletMergeBatch(
@NotEmpty @Size(max = 200) List<Long> orderIds,
@NotNull Boolean palletMergeRequired
) {
}
} }
@@ -42,7 +42,8 @@ public record LastlegOrderSummary(
String relabelStatus, String relabelStatus,
String palletMergeFlag, String palletMergeFlag,
String palletMergeBatchId, String palletMergeBatchId,
List<String> palletMergeOrderNos, @Schema(description = "同一合托组内其他来源入仓单的入仓码")
List<String> palletMergeInboundNos,
String currentStatus, String currentStatus,
String subOrderStatus, String subOrderStatus,
String privateRemark, String privateRemark,
@@ -52,6 +52,7 @@ public class LastlegOrder {
private String relabelOrderNo; private String relabelOrderNo;
private String relabelStatus; private String relabelStatus;
private String palletMergeFlag; private String palletMergeFlag;
private String palletMergeBatchId;
private String currentStatus; private String currentStatus;
private String subOrderStatus; private String subOrderStatus;
private String privateRemark; private String privateRemark;
@@ -26,7 +26,8 @@ public record InboundPlanListResponse(
LocalDateTime appointmentAt, LocalDateTime appointmentAt,
LocalDateTime expectedLoadingAt, LocalDateTime expectedLoadingAt,
String dispatchStatus, String dispatchStatus,
LocalDateTime createdAt LocalDateTime createdAt,
Integer version
) { ) {
public InboundPlanListResponse(Long lineId, Long planId, String planNo, String customerId, public InboundPlanListResponse(Long lineId, Long planId, String planNo, String customerId,
Long orderId, String inboundCode, String orderType, Long orderId, String inboundCode, String orderType,
@@ -39,6 +40,6 @@ public record InboundPlanListResponse(
this(lineId, planId, planNo, customerId, orderId, inboundCode, orderType, warehouseId, this(lineId, planId, planNo, customerId, orderId, inboundCode, orderType, warehouseId,
warehouseCode, warehouseName, fbaWarehouseCode, customerName, packageCount, 0, warehouseCode, warehouseName, fbaWarehouseCode, customerName, packageCount, 0,
volumeCbm, palletCount, status, appointmentNo, appointmentStatus, appointmentAt, volumeCbm, palletCount, status, appointmentNo, appointmentStatus, appointmentAt,
expectedLoadingAt, dispatchStatus, createdAt); expectedLoadingAt, dispatchStatus, createdAt, 0);
} }
} }
@@ -18,15 +18,24 @@ public record InboundPlanResponse(
int pickedPackageCount, int pickedPackageCount,
List<Line> lines, List<Line> lines,
List<Pallet> pallets, List<Pallet> pallets,
List<Allocation> allocations List<Allocation> allocations,
int version
) { ) {
public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId, public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId,
String containerNo, String status, int lineCount, int palletCount, String containerNo, String status, int lineCount, int palletCount,
BigDecimal totalVolumeCbm, List<Line> lines, List<Pallet> pallets, BigDecimal totalVolumeCbm, List<Line> lines, List<Pallet> pallets,
List<Allocation> allocations) { List<Allocation> allocations) {
this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount,
totalVolumeCbm, lines, pallets, allocations, 0);
}
public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId,
String containerNo, String status, int lineCount, int palletCount,
BigDecimal totalVolumeCbm, List<Line> lines, List<Pallet> pallets,
List<Allocation> allocations, int version) {
this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount, this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount,
totalVolumeCbm, lines.stream().mapToInt(Line::requiredPackageCount).sum(), totalVolumeCbm, lines.stream().mapToInt(Line::requiredPackageCount).sum(),
allocations.stream().mapToInt(Allocation::pickedPackageCount).sum(), lines, pallets, allocations); allocations.stream().mapToInt(Allocation::pickedPackageCount).sum(), lines, pallets, allocations, version);
} }
public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId, public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.entity;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Data; import lombok.Data;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -24,4 +25,6 @@ public class InboundPlan {
private BigDecimal totalVolumeCbm; private BigDecimal totalVolumeCbm;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
@Version
private Integer version;
} }
@@ -23,12 +23,9 @@ public interface InboundPlanOrderMapper {
"COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," + "COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," +
"COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," + "COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," +
"COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," + "COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," +
"COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) AS destinationCount," + "CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 " +
"SUM(CASE WHEN d.id IS NOT NULL " + "ELSE COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) END AS destinationCount," +
"AND COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL " + "0 AS destinationMismatchCount " +
"AND COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#') " +
"<> COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) THEN 1 ELSE 0 END) " +
"AS destinationMismatchCount " +
"FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " + "FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " +
"WHERE o.customer_id=#{customerId} AND o.id=#{orderId} " + "WHERE o.customer_id=#{customerId} AND o.id=#{orderId} " +
"GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.pallet_count,o.fba_warehouse_code," + "GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.pallet_count,o.fba_warehouse_code," +
@@ -45,8 +45,9 @@ public interface InboundPlanQueryMapper {
"l.planned_pallet_count palletCount,p.status status,a.appointment_no appointmentNo," + "l.planned_pallet_count palletCount,p.status status,a.appointment_no appointmentNo," +
"a.appointment_status appointmentStatus,a.appointment_at appointmentAt," + "a.appointment_status appointmentStatus,a.appointment_at appointmentAt," +
"a.expected_loading_at expectedLoadingAt," + "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)" + "CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.order_id=l.order_id" +
" THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END dispatchStatus,p.created_at createdAt" + " <if test='customerId != null and customerId != \"\"'>AND di.customer_id=#{customerId}</if>)" +
" THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END dispatchStatus,p.created_at createdAt,p.version version" +
FROM_AND_FILTER + " ORDER BY p.created_at DESC,l.id DESC LIMIT #{limit} OFFSET #{offset}</script>") FROM_AND_FILTER + " ORDER BY p.created_at DESC,l.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<InboundPlanListResponse> rows(@Param("customerId") String customerId, List<InboundPlanListResponse> rows(@Param("customerId") String customerId,
@Param("q") InboundPlanRequests.Query query, @Param("q") InboundPlanRequests.Query query,
@@ -227,9 +227,22 @@ public class InboundPlanService {
if (plan == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); if (plan == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
if (!java.util.Set.of("PENDING_ASSIGNMENT", "ASSIGNED").contains(plan.getStatus())) if (!java.util.Set.of("PENDING_ASSIGNMENT", "ASSIGNED").contains(plan.getStatus()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "Inbound plan has started and cannot be edited"); throw new ResponseStatusException(HttpStatus.CONFLICT, "Inbound plan has started and cannot be edited");
int currentVersion = plan.getVersion() == null ? 0 : plan.getVersion();
if (request.version() != currentVersion)
throw new ResponseStatusException(HttpStatus.CONFLICT, "Inbound plan version is stale; reload before editing");
plan.setOverseasWarehouseId(request.overseasWarehouseId()); plan.setOverseasWarehouseId(request.overseasWarehouseId());
plan.setUpdatedAt(LocalDateTime.now()); plan.setUpdatedAt(LocalDateTime.now());
planMapper.updateById(plan); planMapper.updateById(plan);
// Keep the denormalized destination snapshot used by list queries in sync with the plan.
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getPlanId, plan.getId())
.eq(InboundPlanLine::getCustomerId, plan.getCustomerId()));
for (InboundPlanLine line : lines) {
line.setDestinationWarehouseId(String.valueOf(request.overseasWarehouseId()));
if (trimToNull(request.warehouseCode()) != null) line.setDestinationWarehouseCode(request.warehouseCode().trim());
if (trimToNull(request.warehouseName()) != null) line.setDestinationWarehouseName(request.warehouseName().trim());
lineMapper.updateById(line);
}
return detail(customerId, plan); return detail(customerId, plan);
} }
@@ -342,7 +355,7 @@ public class InboundPlanService {
throw new ResponseStatusException(HttpStatus.CONFLICT, throw new ResponseStatusException(HttpStatus.CONFLICT,
"Inbound code " + order.inboundCode() + " is not ready for an inbound plan"); "Inbound code " + order.inboundCode() + " is not ready for an inbound plan");
} }
if (order.destinationCount() != 1 || order.destinationMismatchCount() != 0 || warehouseKey(order) == null) { if (order.destinationCount() != 1 || warehouseKey(order) == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Inbound code " + order.inboundCode() "Inbound code " + order.inboundCode()
+ " must have one destination warehouse consistent with all cargo details"); + " must have one destination warehouse consistent with all cargo details");
@@ -388,7 +401,8 @@ public class InboundPlanService {
pallet.getActualPackageCount())).toList(), pallet.getActualPackageCount())).toList(),
allocations.stream().map(item -> new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(), allocations.stream().map(item -> new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(),
item.getAllocatedVolumeCbm(), item.getPlannedPackageCount(), item.getCountedPackageCount(), item.getAllocatedVolumeCbm(), item.getPlannedPackageCount(), item.getCountedPackageCount(),
item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList()); item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList(),
plan.getVersion() == null ? 0 : plan.getVersion());
} }
private BigDecimal volume(SourceOrderSnapshot order) { private BigDecimal volume(SourceOrderSnapshot order) {
@@ -85,7 +85,8 @@ public class InventoryQueryController {
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.BalanceQuery query) { @Valid InventoryQueryRequests.BalanceQuery query) {
return csv(service.exportBalances(platformScope(user, customerId), query), "inventory-balances.csv"); String scope = platformScope(user, customerId);
return csv(output -> service.writeBalancesCsv(scope, query, output), "inventory-balances.csv");
} }
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv") @GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv")
@@ -95,11 +96,11 @@ public class InventoryQueryController {
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.LedgerQuery query) { @Valid InventoryQueryRequests.LedgerQuery query) {
return csv(service.exportLedgers(platformScope(user, customerId), query), "inventory-ledger.csv"); String scope = platformScope(user, customerId);
return csv(output -> service.writeLedgersCsv(scope, query, output), "inventory-ledger.csv");
} }
private static ResponseEntity<StreamingResponseBody> csv(byte[] body, String filename) { private static ResponseEntity<StreamingResponseBody> csv(StreamingResponseBody stream, String filename) {
StreamingResponseBody stream = output -> { output.write(body); output.flush(); };
return ResponseEntity.ok() return ResponseEntity.ok()
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename)
@@ -17,13 +17,14 @@ import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
@Service @Service
public class InventoryQueryService { public class InventoryQueryService {
private static final int EXPORT_LIMIT = 10_000;
private static final Set<String> BALANCE_STATUSES = Set.of( private static final Set<String> BALANCE_STATUSES = Set.of(
"AVAILABLE", "HOLD", "ALLOCATED", "PICKED", "IN_TRANSIT", "RECEIVING", "PROCUREMENT"); "AVAILABLE", "HOLD", "ALLOCATED", "PICKED", "IN_TRANSIT", "RECEIVING", "PROCUREMENT");
private static final Set<String> POSITION_STATUSES = Set.of( private static final Set<String> POSITION_STATUSES = Set.of(
@@ -79,34 +80,74 @@ public class InventoryQueryService {
} }
public byte[] exportBalances(String customerId, InventoryQueryRequests.BalanceQuery query) { public byte[] exportBalances(String customerId, InventoryQueryRequests.BalanceQuery query) {
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
output.write(("\uFEFFcustomerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode,destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages,availablePackages,holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages\r\n").getBytes(StandardCharsets.UTF_8));
for (InventoryBalance value : mapper.balances(customerId, query, 0, 10_000)) {
InventoryBalanceView v = balanceView(value);
output.write((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.skuCode()), csv(v.productNameZh()), csv(v.productNameEn()), csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()), csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()), csv(v.procurementPackages())) + "\r\n").getBytes(StandardCharsets.UTF_8));
}
return output.toByteArray();
} catch (IOException e) { throw new IllegalStateException("Unable to export inventory balances", e); }
}
public void writeBalancesCsv(String customerId, InventoryQueryRequests.BalanceQuery query, OutputStream output)
throws IOException {
validateStatus(query.inventoryStatus(), BALANCE_STATUSES); validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
List<String> rows = new ArrayList<>(); output.write(("\uFEFFcustomerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," +
rows.add("customerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," +
"destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages,availablePackages," + "destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages,availablePackages," +
"holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages"); "holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages\r\n").getBytes(StandardCharsets.UTF_8));
mapper.balances(customerId, query, 0, EXPORT_LIMIT).stream().map(this::balanceView).forEach(v -> rows.add( final int batchSize = 1000;
long offset = 0;
while (true) {
List<InventoryBalanceView> batch = mapper.balances(customerId, query, offset, batchSize).stream().map(this::balanceView).toList();
if (batch.isEmpty()) break;
for (InventoryBalanceView v : batch) output.write((
String.join(",", csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseCode()), csv(v.overseasWarehouseName()), 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.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.inboundOrderId()),
csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.skuCode()), csv(v.productNameZh()), csv(v.productNameEn()), csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.skuCode()), csv(v.productNameZh()), csv(v.productNameEn()),
csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()), csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()),
csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()), csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()),
csv(v.procurementPackages())))); csv(v.procurementPackages())) + "\r\n").getBytes(StandardCharsets.UTF_8));
return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8); output.flush();
if (batch.size() < batchSize) break;
offset += batchSize;
}
} }
public byte[] exportLedgers(String customerId, InventoryQueryRequests.LedgerQuery query) { public byte[] exportLedgers(String customerId, InventoryQueryRequests.LedgerQuery query) {
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
validateDateRange(query);
output.write(("\uFEFFoccurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo,inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo,beforePackages,deltaPackages,afterPackages,operatorId,remark\r\n").getBytes(StandardCharsets.UTF_8));
for (InventoryLedger value : mapper.ledgers(customerId, query, 0, 10_000)) {
InventoryLedgerView v = ledgerView(value);
output.write((String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.overseasWarehouseId()), csv(v.destinationWarehouseCode()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.businessType()), csv(v.businessOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()), csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()), csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark())) + "\r\n").getBytes(StandardCharsets.UTF_8));
}
return output.toByteArray();
} catch (IOException e) { throw new IllegalStateException("Unable to export inventory ledger", e); }
}
public void writeLedgersCsv(String customerId, InventoryQueryRequests.LedgerQuery query, OutputStream output)
throws IOException {
validateDateRange(query); validateDateRange(query);
List<String> rows = new ArrayList<>(); output.write(("\uFEFFoccurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo," +
rows.add("occurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo," +
"inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo," + "inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo," +
"beforePackages,deltaPackages,afterPackages,operatorId,remark"); "beforePackages,deltaPackages,afterPackages,operatorId,remark\r\n").getBytes(StandardCharsets.UTF_8));
mapper.ledgers(customerId, query, 0, EXPORT_LIMIT).stream().map(this::ledgerView).forEach(v -> rows.add( final int batchSize = 1000;
long offset = 0;
while (true) {
List<InventoryLedgerView> batch = mapper.ledgers(customerId, query, offset, batchSize).stream().map(this::ledgerView).toList();
if (batch.isEmpty()) break;
for (InventoryLedgerView v : batch) output.write((
String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.overseasWarehouseId()), String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.overseasWarehouseId()),
csv(v.destinationWarehouseCode()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.destinationWarehouseCode()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()),
csv(v.businessType()), csv(v.businessOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()), csv(v.businessType()), csv(v.businessOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()),
csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()), csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()),
csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark())))); csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark())) + "\r\n").getBytes(StandardCharsets.UTF_8));
return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8); output.flush();
if (batch.size() < batchSize) break;
offset += batchSize;
}
} }
private InventoryBalanceView balanceView(InventoryBalance value) { private InventoryBalanceView balanceView(InventoryBalance value) {
@@ -11,10 +11,18 @@ import java.util.List;
@Mapper @Mapper
public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> { public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
record PalletMergeRelationRow(Long orderId, String relatedOrderNo, String mergeGroupNo) {} record PalletMergeRelationRow(Long orderId, String relatedInboundNo, String mergeGroupNo) {}
@Select("<script>SELECT relation.order_id AS orderId, related.order_no AS relatedOrderNo, " + @Select("<script>SELECT relation.order_id AS orderId," +
"COALESCE(NULLIF(related.inbound_code,''),NULLIF(related.inbound_order_no,''),related.source_order_no) AS relatedInboundNo, " +
"relation.merge_group_no AS mergeGroupNo FROM (" + "relation.merge_group_no AS mergeGroupNo FROM (" +
"SELECT marked.customer_id,marked.id AS order_id,related.id AS related_order_id," +
"marked.pallet_merge_batch_id AS merge_group_no FROM ll_order marked " +
"JOIN ll_order related ON related.customer_id=marked.customer_id " +
"AND related.pallet_merge_batch_id=marked.pallet_merge_batch_id AND related.id&lt;&gt;marked.id " +
"WHERE marked.pallet_merge_batch_id IS NOT NULL AND marked.id IN " +
"<foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"UNION ALL " +
"SELECT customer_id,source_order_id AS order_id,target_order_id AS related_order_id,merge_group_no " + "SELECT customer_id,source_order_id AS order_id,target_order_id AS related_order_id,merge_group_no " +
"FROM ll_inbound_pallet_merge WHERE status='CONFIRMED' AND source_order_id IN " + "FROM ll_inbound_pallet_merge WHERE status='CONFIRMED' AND source_order_id IN " +
"<foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " + "<foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
@@ -59,9 +67,17 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
@Param("warehouseId") String warehouseId, @Param("warehouseId") String warehouseId,
@Param("warehouseName") String warehouseName); @Param("warehouseName") String warehouseName);
@Update("UPDATE ll_order SET pallet_merge_flag=#{flag},updated_at=#{updatedAt} " + @Update("UPDATE ll_order SET pallet_merge_flag=#{flag}," +
"pallet_merge_batch_id=CASE WHEN #{flag} IS NULL THEN NULL ELSE pallet_merge_batch_id END," +
"updated_at=#{updatedAt} " +
"WHERE customer_id=#{customerId} AND id=#{id}") "WHERE customer_id=#{customerId} AND id=#{id}")
int updatePalletMergeFlag(@Param("customerId") String customerId, @Param("id") long id, int updatePalletMergeFlag(@Param("customerId") String customerId, @Param("id") long id,
@Param("flag") String flag, @Param("flag") String flag,
@Param("updatedAt") java.time.LocalDateTime updatedAt); @Param("updatedAt") java.time.LocalDateTime updatedAt);
@Update("UPDATE ll_order SET pallet_merge_flag=#{flag},pallet_merge_batch_id=#{batchId},updated_at=#{updatedAt} " +
"WHERE customer_id=#{customerId} AND id=#{id}")
int updatePalletMergeBatch(@Param("customerId") String customerId, @Param("id") long id,
@Param("flag") String flag, @Param("batchId") String batchId,
@Param("updatedAt") java.time.LocalDateTime updatedAt);
} }
@@ -29,6 +29,9 @@ import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -181,6 +184,46 @@ public class LastlegOrderService {
operationLogMapper.insert(operation); operationLogMapper.insert(operation);
} }
@Transactional
public void setPalletMergeBatch(String customerId, LastlegOrderRequests.SetPalletMergeBatch request,
String operatorName) {
List<Long> ids = request.orderIds().stream().filter(Objects::nonNull).distinct().toList();
if (ids.isEmpty()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one order is required");
List<LastlegOrder> orders = orderMapper.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
.in(LastlegOrder::getId, ids).last("FOR UPDATE"));
if (orders.size() != ids.size()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more tail-leg orders were not found");
}
if (orders.stream().map(LastlegOrder::getCustomerId).distinct().count() != 1) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Pallet merge orders must belong to one customer");
}
if (orders.stream().map(order -> StringUtils.hasText(order.getTransferWarehouseCode())
? order.getTransferWarehouseCode() : order.getFbaWarehouseCode()).filter(Objects::nonNull).distinct().count() > 1) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Pallet merge orders must have one destination warehouse");
}
if (orders.stream().anyMatch(order -> "INBOUND_COMPLETED".equals(order.getCurrentStatus())
|| PALLET_MERGED.equals(order.getPalletMergeFlag()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed pallet merge orders cannot be changed");
}
String batchId = request.palletMergeRequired()
? "PMG-" + java.util.UUID.randomUUID().toString().replace("-", "") : null;
String flag = request.palletMergeRequired() ? PALLET_MERGE_REQUIRED : null;
LocalDateTime now = LocalDateTime.now();
for (LastlegOrder order : orders) {
if (orderMapper.updatePalletMergeBatch(order.getCustomerId(), order.getId(), flag, batchId, now) != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Pallet merge group was not updated");
}
LastlegOrderOperationLog operation = new LastlegOrderOperationLog();
operation.setId(IdWorker.getId()); operation.setCustomerId(order.getCustomerId());
operation.setOrderId(order.getId());
operation.setOperationNode(request.palletMergeRequired() ? "批量标记合托" : "取消合托标记");
operation.setOperatorName(operatorName);
operation.setResult(request.palletMergeRequired() ? "PDA合托分组 " + batchId : "已取消PDA合托分组");
operation.setCreatedAt(now); operationLogMapper.insert(operation);
}
}
@Transactional @Transactional
public int transferDestination(String customerId, LastlegOrderRequests.TransferDestination request) { public int transferDestination(String customerId, LastlegOrderRequests.TransferDestination request) {
List<Long> ids = request.orderIds().stream().filter(Objects::nonNull).distinct().toList(); List<Long> ids = request.orderIds().stream().filter(Objects::nonNull).distinct().toList();
@@ -236,12 +279,25 @@ public class LastlegOrderService {
} }
public byte[] exportCsv(String customerId, LastlegOrderRequests.Query query) { public byte[] exportCsv(String customerId, LastlegOrderRequests.Query query) {
List<LastlegOrder> orders = orderMapper.selectList(filter(customerId, query) try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
.orderByDesc(LastlegOrder::getCreatedAt).last("LIMIT 10000")); writeExportCsv(customerId, query, output);
List<String> lines = new ArrayList<>(); return output.toByteArray();
lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)"); } catch (IOException e) {
for (LastlegOrder order : orders) { throw new IllegalStateException("Unable to export tail-leg orders", e);
lines.add(String.join(",", List.of( }
}
public void writeExportCsv(String customerId, LastlegOrderRequests.Query query, OutputStream output)
throws IOException {
output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8));
final int batchSize = 1000;
long page = 1;
while (true) {
List<LastlegOrder> orders = orderMapper.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, batchSize),
filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId)).getRecords();
if (orders.isEmpty()) break;
for (LastlegOrder order : orders) {
String line = String.join(",", List.of(
csv(order.getId()), csv(order.getOrderNo()), csv(LastlegOrderType.labelOf(order.getOrderType())), csv(order.getId()), csv(order.getOrderNo()), csv(LastlegOrderType.labelOf(order.getOrderType())),
csv(order.getInboundCode()), csv(order.getInboundCode()),
csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()), csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()),
@@ -251,9 +307,13 @@ public class LastlegOrderService {
csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()), csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()),
csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()), csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()),
csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()), csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()),
csv(order.getCustomerRemark()), csv(order.getWarehouseRemark())))); csv(order.getCustomerRemark()), csv(order.getWarehouseRemark()))) + "\r\n";
output.write(line.getBytes(StandardCharsets.UTF_8));
}
output.flush();
if (orders.size() < batchSize) break;
page++;
} }
return ("\uFEFF" + String.join("\r\n", lines)).getBytes(StandardCharsets.UTF_8);
} }
private LambdaQueryWrapper<LastlegOrder> filter(String customerId, LastlegOrderRequests.Query query) { private LambdaQueryWrapper<LastlegOrder> filter(String customerId, LastlegOrderRequests.Query query) {
@@ -320,7 +380,7 @@ public class LastlegOrderService {
o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(), o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(),
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getRelabelOrderNo(), o.getRelabelStatus(), o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getRelabelOrderNo(), o.getRelabelStatus(),
o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null), o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null),
related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedOrderNo).filter(Objects::nonNull).distinct().toList(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedInboundNo).filter(Objects::nonNull).distinct().toList(),
o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(), o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(),
o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt()); o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt());
} }
@@ -77,6 +77,7 @@ CREATE TABLE IF NOT EXISTS ll_order (
relabel_order_no VARCHAR(128) COMMENT '换标单号', relabel_order_no VARCHAR(128) COMMENT '换标单号',
relabel_status VARCHAR(32) COMMENT '换标状态', relabel_status VARCHAR(32) COMMENT '换标状态',
pallet_merge_flag VARCHAR(32) COMMENT '合托标记', pallet_merge_flag VARCHAR(32) COMMENT '合托标记',
pallet_merge_batch_id VARCHAR(64) NULL COMMENT '平台预标记合托分组号',
current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态:PENDING_ASSIGNMENT待分配、PENDING_INBOUND待入库、INBOUND_COMPLETED入库完成、PENDING_DELIVERY待派送、DELIVERING派送中、DELIVERED派送完成', current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态:PENDING_ASSIGNMENT待分配、PENDING_INBOUND待入库、INBOUND_COMPLETED入库完成、PENDING_DELIVERY待派送、DELIVERING派送中、DELIVERED派送完成',
sub_order_status VARCHAR(255) NULL COMMENT '子单据状态汇总', sub_order_status VARCHAR(255) NULL COMMENT '子单据状态汇总',
private_remark VARCHAR(1000) COMMENT '备注(仅自己)', private_remark VARCHAR(1000) COMMENT '备注(仅自己)',
@@ -97,6 +98,9 @@ CREATE TABLE IF NOT EXISTS ll_order (
KEY idx_ll_order_list(customer_id,current_status,created_at) KEY idx_ll_order_list(customer_id,current_status,created_at)
) ENGINE=InnoDB COMMENT='尾程订单主表'; ) ENGINE=InnoDB COMMENT='尾程订单主表';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_batch_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag'));
PREPARE ll_order_merge_batch_ddl FROM @ddl; EXECUTE ll_order_merge_batch_ddl; DEALLOCATE PREPARE ll_order_merge_batch_ddl;
CREATE TABLE IF NOT EXISTS ll_order_detail ( CREATE TABLE IF NOT EXISTS ll_order_detail (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
@@ -432,6 +436,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan (
total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '集装箱总体积(立方米)', total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '集装箱总体积(立方米)',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
version INT NOT NULL DEFAULT 0 COMMENT '入库计划乐观锁版本',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_ll_inbound_plan_no (plan_no), UNIQUE KEY uk_ll_inbound_plan_no (plan_no),
UNIQUE KEY uk_ll_inbound_plan_source_order (customer_id, source_order_id), UNIQUE KEY uk_ll_inbound_plan_source_order (customer_id, source_order_id),
@@ -18,6 +18,8 @@ import org.springframework.web.server.ResponseStatusException;
import java.util.Set; import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.isNull;
@@ -50,6 +52,32 @@ class LastlegOrderControllerTest {
verify(service).page(eq("200"), eq(query)); verify(service).page(eq("200"), eq(query));
} }
@Test
void exportBuildsCsvBeforeReturningTheResponse() {
byte[] csv = "csv".getBytes(java.nio.charset.StandardCharsets.UTF_8);
when(service.exportCsv("200", query)).thenReturn(csv);
var response = controller.export(user(0L, "PLATFORM"), "200", query);
assertArrayEquals(csv, response.getBody());
verify(service).exportCsv("200", query);
}
@Test
void platformOperatorCanExportAllCustomersWithoutFilters() {
byte[] csv = "all customers".getBytes(java.nio.charset.StandardCharsets.UTF_8);
LastlegOrderRequests.Query emptyQuery = new LastlegOrderRequests.Query(
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null);
when(service.exportCsv(null, emptyQuery)).thenReturn(csv);
var response = controller.export(user(0L, "PLATFORM"), null, emptyQuery);
assertEquals(Long.valueOf(1), emptyQuery.page());
assertEquals(Long.valueOf(20), emptyQuery.size());
assertArrayEquals(csv, response.getBody());
verify(service).exportCsv(isNull(), eq(emptyQuery));
}
@Test @Test
void iamSecurityEventsAreRejectedBeforeLoadingCompletedConsumer() { void iamSecurityEventsAreRejectedBeforeLoadingCompletedConsumer() {
EventEnvelope event = mock(EventEnvelope.class); EventEnvelope event = mock(EventEnvelope.class);
@@ -16,6 +16,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -25,6 +26,13 @@ class DispatchPdaWorkflowServiceTest {
@Mock private DispatchPdaOperationMapper operations; @Mock private DispatchPdaOperationMapper operations;
@Mock private OutboundOrderMapper outboundOrders; @Mock private OutboundOrderMapper outboundOrders;
@Test
void labelsTransferredPalletsWithOriginalAndCurrentFbaWarehouses() {
assertEquals("ONT8 -> ONT", DispatchPdaWorkflowService.warehouseRouteLabel("ONT8", "ONT"));
assertNull(DispatchPdaWorkflowService.warehouseRouteLabel("ONT8", "ont8"));
assertNull(DispatchPdaWorkflowService.warehouseRouteLabel(null, "ONT"));
}
@Test @Test
void pushRequiresVehiclePlateBeforeCreatingPdaTask() { void pushRequiresVehiclePlateBeforeCreatingPdaTask() {
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.inboundplan.mapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InboundPlanOrderMapperContractTest {
@Test
void transferWarehouseIsTheEffectivePalletDestination() throws Exception {
Select select = InboundPlanOrderMapper.class
.getMethod("lockOrder", String.class, Long.class)
.getAnnotation(Select.class);
String sql = String.join(" ", select.value());
assertTrue(sql.contains("COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,'')))"));
assertTrue(sql.contains("COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,'')))"));
assertTrue(sql.contains("THEN 1 ELSE COUNT(DISTINCT"));
assertTrue(sql.contains("0 AS destinationMismatchCount"));
}
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.inboundplan.mapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InboundPlanQueryMapperContractTest {
@Test
void platformListReadsVersionAndDoesNotRequireANullDispatchCustomer() throws Exception {
Select select = InboundPlanQueryMapper.class
.getMethod("rows", String.class,
com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests.Query.class,
long.class, long.class)
.getAnnotation(Select.class);
String sql = String.join(" ", select.value());
assertTrue(sql.contains("p.version version"));
assertTrue(sql.contains("WHERE di.order_id=l.order_id"));
assertTrue(sql.contains("<if test='customerId != null and customerId != \"\"'>AND di.customer_id=#{customerId}</if>"));
}
}
@@ -163,13 +163,19 @@ class InboundPlanServiceTest {
} }
@Test @Test
void rejectsTransferWarehouseThatDiffersFromCargoSnapshots() { void transferWarehouseOverridesOriginalCargoDestination() {
SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1", SourceOrderSnapshot transferred = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1",
"DESTINATION_ASSIGNED", null, BigDecimal.ONE, 1, "WH-B", "WH-B", "Warehouse B", 1, 1); "DESTINATION_ASSIGNED", null, BigDecimal.ONE, 1, "WH-B", "WH-B", "Warehouse B", 1, 1);
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(invalid)); when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(transferred));
when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()))
.thenReturn(1);
assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L)); service.convert("100", 1L);
verify(planMapper, never()).insert(any(InboundPlan.class));
ArgumentCaptor<InboundPlanPallet> pallet = ArgumentCaptor.forClass(InboundPlanPallet.class);
verify(palletMapper).insert(pallet.capture());
assertEquals("WH-B", pallet.getValue().getDestinationWarehouseId());
assertEquals("WH-B", pallet.getValue().getDestinationWarehouseCode());
} }
@Test @Test
@@ -194,7 +194,7 @@ class LastlegOrderServiceTest {
} }
@Test @Test
void pageReturnsConfirmedPalletMergeRelatedOrderNumbers() { void pageReturnsPalletMergeRelatedInboundNumbers() {
LastlegOrder order = order(11L, "100", "INBOUND_COMPLETED", "IB-1"); LastlegOrder order = order(11L, "100", "INBOUND_COMPLETED", "IB-1");
order.setPalletMergeFlag("MERGED"); order.setPalletMergeFlag("MERGED");
var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder>(1, 10); var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder>(1, 10);
@@ -202,15 +202,15 @@ class LastlegOrderServiceTest {
resultPage.setTotal(1); resultPage.setTotal(1);
when(orders.selectPage(any(), any())).thenReturn(resultPage); when(orders.selectPage(any(), any())).thenReturn(resultPage);
when(orders.findPalletMergeRelations(List.of(11L))).thenReturn(List.of( when(orders.findPalletMergeRelations(List.of(11L))).thenReturn(List.of(
new LastlegOrderMapper.PalletMergeRelationRow(11L, "LMO-RELATED-2", "PMG-1"), new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-2", "PMG-1"),
new LastlegOrderMapper.PalletMergeRelationRow(11L, "LMO-RELATED-3", "PMG-1"))); new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-3", "PMG-1")));
var query = new LastlegOrderRequests.Query(1, 10, null, null, null, null, var query = new LastlegOrderRequests.Query(1, 10, null, null, null, null,
null, null, null, null, null, null, null, null, null); null, null, null, null, null, null, null, null, null);
var result = service.page(null, query); var result = service.page(null, query);
assertEquals("PMG-1", result.records().get(0).palletMergeBatchId()); assertEquals("PMG-1", result.records().get(0).palletMergeBatchId());
assertEquals(List.of("LMO-RELATED-2", "LMO-RELATED-3"), result.records().get(0).palletMergeOrderNos()); assertEquals(List.of("INBOUND-2", "INBOUND-3"), result.records().get(0).palletMergeInboundNos());
} }
@Test @Test
+7
View File
@@ -239,6 +239,9 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan (
UNIQUE KEY uk_ll_inbound_plan_container (tenant_id,container_no) UNIQUE KEY uk_ll_inbound_plan_container (tenant_id,container_no)
) ENGINE=InnoDB COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单'; ) ENGINE=InnoDB COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''入库计划乐观锁版本'' AFTER updated_at'));
PREPARE ll_inbound_plan_version_ddl FROM @ddl; EXECUTE ll_inbound_plan_version_ddl; DEALLOCATE PREPARE ll_inbound_plan_version_ddl;
CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( CREATE TABLE IF NOT EXISTS ll_inbound_plan_line (
id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)',
plan_id BIGINT NOT NULL COMMENT '入库计划ID', plan_id BIGINT NOT NULL COMMENT '入库计划ID',
@@ -440,6 +443,7 @@ CREATE TABLE IF NOT EXISTS ll_order (
relabel_order_no VARCHAR(128) NULL COMMENT '换标单号', relabel_order_no VARCHAR(128) NULL COMMENT '换标单号',
relabel_status VARCHAR(32) NULL COMMENT '换标状态', relabel_status VARCHAR(32) NULL COMMENT '换标状态',
pallet_merge_flag VARCHAR(32) NULL COMMENT '合托标记', pallet_merge_flag VARCHAR(32) NULL COMMENT '合托标记',
pallet_merge_batch_id VARCHAR(64) NULL COMMENT '平台预标记合托分组号',
current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态', current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态',
private_remark VARCHAR(1000) NULL COMMENT '备注(仅自己)', private_remark VARCHAR(1000) NULL COMMENT '备注(仅自己)',
customer_remark VARCHAR(1000) NULL COMMENT '备注(客户)', customer_remark VARCHAR(1000) NULL COMMENT '备注(客户)',
@@ -457,6 +461,9 @@ CREATE TABLE IF NOT EXISTS ll_order (
KEY idx_ll_order_list (tenant_id,current_status,created_at) KEY idx_ll_order_list (tenant_id,current_status,created_at)
) ENGINE=InnoDB COMMENT='尾程订单主表'; ) ENGINE=InnoDB COMMENT='尾程订单主表';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_batch_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag'));
PREPARE ll_order_merge_batch_ddl FROM @ddl; EXECUTE ll_order_merge_batch_ddl; DEALLOCATE PREPARE ll_order_merge_batch_ddl;
CREATE TABLE IF NOT EXISTS ll_order_detail ( CREATE TABLE IF NOT EXISTS ll_order_detail (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键',
order_id BIGINT NOT NULL COMMENT '尾程订单ID', order_id BIGINT NOT NULL COMMENT '尾程订单ID',