Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java
#	warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java
This commit is contained in:
2026-09-09 18:20:40 +08:00
58 changed files with 1787 additions and 932 deletions
@@ -83,7 +83,8 @@ public class PlatformFbaPricingController {
}
@PutMapping("/published")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:adjust')")
@PreAuthorize("hasAnyAuthority('firstleg:fba-pricing:adjust',"
+ " 'firstleg:inbound:confirm-send-fee')")
@BusinessAudit(event = "FBA_FEE_ADJUST", resourceType = "INBOUND", resourceId = "#inboundId",
description = "调整初算或已发布入库费用,不回退待分柜状态")
public ApiResponse<FeeSnapshotResponse> adjustPublished(
@@ -13,8 +13,11 @@ import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.LifecycleResolution;
import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelManifest;
import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelPrintRequest;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingListService;
import com.baiyue.firstleg.service.LoadingPalletLabelService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
@@ -32,6 +35,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.ResponseEntity;
/** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */
@Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询")
@@ -41,6 +45,8 @@ import org.springframework.web.bind.annotation.RestController;
public class PlatformLoadingListController {
@Resource
private LoadingListService service;
@Resource
private LoadingPalletLabelService palletLabels;
@Operation(summary = "分页查询预装清单")
@GetMapping("/loading-plans")
@@ -118,4 +124,26 @@ public class PlatformLoadingListController {
return ApiResponse.success(service.confirmedDetail(planId, warehouseId));
}
@Operation(summary = "生成并查询散货托盘码", description = "仅为散货按真实集装箱号与 FBA 仓稳定生成托盘码;一件代发不参与。")
@PostMapping("/loading-plans/{planId}/pallet-labels/prepare")
@PreAuthorize("hasAnyAuthority('platform:firstleg:preload:list','platform:firstleg:loading-list:list','platform:firstleg:container:list')")
public ApiResponse<PalletLabelManifest> preparePalletLabels(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(palletLabels.prepare(planId, warehouseId));
}
@Operation(summary = "打印散货托盘码", description = "不传仓码与张数时打印全部;按仓打印时返回该仓从第 1 张开始的指定数量,用于补打。")
@PostMapping(value = "/loading-plans/{planId}/pallet-labels/print", produces = "application/pdf")
@PreAuthorize("hasAnyAuthority('platform:firstleg:preload:list','platform:firstleg:loading-list:list','platform:firstleg:container:list')")
public ResponseEntity<byte[]> printPalletLabels(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId,
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
@Valid @RequestBody PalletLabelPrintRequest request) {
FirstlegScopeGuard.platform(user);
return palletLabels.print(planId, warehouseId, request, requestId);
}
}
@@ -125,6 +125,37 @@ public final class LoadingListDtos {
) {
}
public record PalletLabelPrintRequest(
@Size(max = 64) String destinationWarehouseCode,
@Min(1) @Max(999) Integer count
) {
}
public record PalletLabel(
Long labelId, Long itemId, Long accountId, Long inboundId, String inboundCode,
String containerNo, String destinationWarehouseCode, String destinationWarehouseName,
int palletIndex, String palletCode
) {
}
public record PalletLabelGroup(
String destinationWarehouseCode, String destinationWarehouseName,
int totalCount, List<PalletLabel> labels
) {
public PalletLabelGroup {
labels = labels == null ? List.of() : List.copyOf(labels);
}
}
public record PalletLabelManifest(
Long planId, String planNo, String containerNo, int totalCount,
List<PalletLabelGroup> groups
) {
public PalletLabelManifest {
groups = groups == null ? List.of() : List.copyOf(groups);
}
}
public record PreloadSummary(
Long planId,
Long containerBookingId,
@@ -0,0 +1,70 @@
package com.baiyue.firstleg.mapper;
import lombok.Getter;
import lombok.Setter;
import org.apache.ibatis.annotations.Insert;
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 LoadingPalletLabelMapper {
@Select("""
SELECT p.id AS plan_id,p.plan_no,p.plan_status,b.container_no,b.warehouse_id,
i.id AS item_id,i.account_id,i.inbound_id,i.inbound_code,
i.destination_warehouse_code,i.destination_warehouse_name,
COALESCE(NULLIF(i.pallet_count,0),
CEIL(COALESCE(NULLIF(o.pallet_count,0),
(SELECT COUNT(*) FROM fl_inbound_weight_result weight
WHERE weight.tenant_id=i.account_id AND weight.inbound_id=i.inbound_id
AND weight.deleted=0 AND weight.recognition_status='CONFIRMED'),0)
* i.package_count / NULLIF(o.total_package_count,0)),0) AS pallet_count,
COALESCE(o.fulfillment_type,'STANDARD') AS fulfillment_type,
o.destination_type,i.load_sequence
FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0
LEFT JOIN fl_inbound_order o ON o.id=i.inbound_id AND o.tenant_id=i.account_id AND o.deleted=0
WHERE p.id=#{planId}
ORDER BY i.destination_warehouse_code,i.load_sequence,i.id
FOR UPDATE
""")
List<SourceRow> lockSources(@Param("planId") long planId);
@Select("""
SELECT id AS label_id,loading_plan_id AS plan_id,loading_plan_item_id AS item_id,
account_id,inbound_id,inbound_code,container_no,destination_warehouse_code,
destination_warehouse_name,pallet_index,pallet_code
FROM fl_loading_pallet_label WHERE loading_plan_id=#{planId}
ORDER BY destination_warehouse_code,pallet_index,id
""")
List<LabelRow> labels(@Param("planId") long planId);
@Insert("""
INSERT INTO fl_loading_pallet_label
(id,loading_plan_id,loading_plan_item_id,account_id,inbound_id,inbound_code,
container_no,destination_warehouse_code,destination_warehouse_name,pallet_index,pallet_code,created_at)
VALUES
(#{labelId},#{planId},#{itemId},#{accountId},#{inboundId},#{inboundCode},
#{containerNo},#{destinationWarehouseCode},#{destinationWarehouseName},#{palletIndex},#{palletCode},CURRENT_TIMESTAMP(3))
""")
int insert(LabelRow row);
@Getter @Setter
class SourceRow {
private Long planId; private String planNo; private String planStatus; private String containerNo;
private Long warehouseId; private Long itemId; private Long accountId; private Long inboundId;
private String inboundCode; private String destinationWarehouseCode; private String destinationWarehouseName;
private Integer palletCount; private String fulfillmentType; private Integer loadSequence;
private String destinationType;
}
@Getter @Setter
class LabelRow {
private Long labelId; private Long planId; private Long itemId; private Long accountId; private Long inboundId;
private String inboundCode; private String containerNo; private String destinationWarehouseCode;
private String destinationWarehouseName; private Integer palletIndex; private String palletCode;
}
}
@@ -17,6 +17,7 @@ import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
@@ -44,6 +45,8 @@ public class LoadingCompletedOutboxService {
private static final long HANDOVER_VERSION = 2L;
@Resource private LoadingCompletedEventMapper snapshotMapper;
@Resource private LoadingCartonManifestMapper cartonManifestMapper;
@Resource private LoadingPalletLabelMapper palletLabelMapper;
@Resource private LoadingPalletLabelService palletLabelService;
@Resource private InboundOutboxMapper outboxMapper;
@Resource private ObjectMapper objectMapper;
@@ -61,12 +64,15 @@ public class LoadingCompletedOutboxService {
if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件");
validateContainerIdentity(rows);
validateCustomerSnapshots(rows);
palletLabelService.prepare(planId, rows.get(0).getWarehouseId());
Map<Long, List<String>> cartonMarks = cartonLabelPools(rows);
List<PalletRow> palletRows = palletRows(planId);
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels = palletLabelMapper.labels(planId);
return rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId,
java.util.TreeMap::new, java.util.stream.Collectors.toList()))
.entrySet().stream().map(entry -> envelope(entry.getKey(), entry.getValue(),
palletRows.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(), cartonMarks))
palletRows.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(),
standardLabels.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(), cartonMarks))
.toList();
}
@@ -91,13 +97,15 @@ public class LoadingCompletedOutboxService {
}
private EventEnvelope envelope(long accountId, List<Row> rows, List<PalletRow> palletRows,
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels,
Map<Long, List<String>> cartonMarks) {
Row first = rows.get(0);
Map<Long, List<Row>> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy(
Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList()));
List<FirstLegLoadingPlanCompletedV2.Order> orders = byInbound.values().stream()
.map(value -> order(value, palletRows.stream()
.filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList();
.filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(),
standardLabels.stream().filter(item -> Objects.equals(item.getInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList();
var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()),
first.getPlanNo(), string(first.getContainerBookingId()),
ContainerNumbers.requireCanonical(first.getContainerNo()), first.getCabinetNo(),
@@ -114,6 +122,7 @@ public class LoadingCompletedOutboxService {
}
private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows,
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels,
Map<Long, List<String>> cartonMarks) {
List<Row> sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList();
Row first = sorted.get(0);
@@ -129,15 +138,18 @@ public class LoadingCompletedOutboxService {
String fulfillmentType = fulfillmentType(sorted);
String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG";
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> pallets = dropship(fulfillmentType)
? palletSnapshots(sorted, palletRows) : List.of();
? palletSnapshots(sorted, palletRows) : standardLabels.stream()
.map(label -> new FirstLegLoadingPlanCompletedV2.PalletSnapshot(label.getPalletCode(), List.of())).toList();
if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照");
}
Integer frozenPalletCount = dropship(fulfillmentType) || standardLabels.isEmpty()
? actualPalletCount(palletRows) : Integer.valueOf(standardLabels.size());
return new FirstLegLoadingPlanCompletedV2.Order(string(first.getSourceInboundId()),
string(first.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(),
string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()),
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount),
sumNullableInt(sorted, Row::getProductQuantity), actualPalletCount(palletRows),
sumNullableInt(sorted, Row::getProductQuantity), frozenPalletCount,
sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg),
sum(sorted, Row::getVolumeCbm), sum(sorted, Row::getDimensionalWeightKg), items,
string(first.getAccountId()), first.getCustomerName().trim(), orderType, pallets,
@@ -0,0 +1,147 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.firstleg.client.PrintPdfClient;
import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabel;
import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelGroup;
import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelManifest;
import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelPrintRequest;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.SourceRow;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
/** 散货托盘码是柜号与 FBA 仓维度的稳定业务标识;一件代发继续使用装箱打托事实。 */
@Service
public class LoadingPalletLabelService {
private static final BigDecimal PAGE_MM = new BigDecimal("100");
@Resource private LoadingPalletLabelMapper mapper;
@Resource private PrintPdfClient printClient;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@Transactional
public PalletLabelManifest prepare(long planId, long warehouseId) {
List<SourceRow> sources = mapper.lockSources(planId);
if (sources.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在或没有有效明细");
SourceRow first = sources.get(0);
if (!Objects.equals(first.getWarehouseId(), warehouseId)) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不属于当前仓库");
if (!List.of("LOADING", "COMPLETED").contains(first.getPlanStatus())) {
throw new BizException(ResultCode.CONFLICT, "请先确认预装清单,再生成散货托盘码");
}
String containerNo;
try { containerNo = ContainerNumbers.requireCanonical(first.getContainerNo()); }
catch (IllegalArgumentException exception) { throw new BizException(ResultCode.CONFLICT, "请先补录有效集装箱号"); }
List<SourceRow> standard = sources.stream().filter(row -> !"ONE_CLICK_DROPSHIP".equals(row.getFulfillmentType()))
.filter(row -> "FBA".equals(row.getDestinationType()))
.filter(row -> Objects.requireNonNullElse(row.getPalletCount(), 0) > 0).toList();
if (standard.stream().anyMatch(row -> !StringUtils.hasText(row.getDestinationWarehouseCode()))) {
throw new BizException(ResultCode.CONFLICT, "散货明细缺少 FBA 仓编码,不能生成托盘码");
}
List<LabelRow> existing = mapper.labels(planId);
List<LabelRow> expected = expected(first, standard, containerNo);
if (existing.isEmpty()) {
expected.forEach(row -> { if (mapper.insert(row) != 1) throw new BizException(ResultCode.CONFLICT, "托盘码生成失败,请刷新后重试"); });
existing = mapper.labels(planId);
}
requireSame(expected, existing);
return manifest(first, existing);
}
@Transactional
public ResponseEntity<byte[]> print(long planId, long warehouseId, PalletLabelPrintRequest request, String requestId) {
PalletLabelManifest manifest = prepare(planId, warehouseId);
String warehouseCode = normalize(request == null ? null : request.destinationWarehouseCode());
Integer count = request == null ? null : request.count();
if ((warehouseCode == null) != (count == null)) throw new BizException(ResultCode.BAD_REQUEST, "按仓打印时必须同时选择 FBA 仓和打印张数");
List<PalletLabel> selected = warehouseCode == null ? manifest.groups().stream().flatMap(group -> group.labels().stream()).toList()
: manifest.groups().stream().filter(group -> group.destinationWarehouseCode().equalsIgnoreCase(warehouseCode))
.flatMap(group -> group.labels().stream()).limit(count).toList();
if (selected.isEmpty() || (count != null && selected.size() != count)) throw new BizException(ResultCode.BAD_REQUEST, "所选 FBA 仓或打印张数无效");
List<PrintPdfClient.Label> labels = selected.stream().map(label -> new PrintPdfClient.Label(
label.palletCode(), label.destinationWarehouseName(), "PALLET " + label.palletIndex(),
"CONTAINER " + label.containerNo(), label.palletCode(), null)).toList();
String suffix = warehouseCode == null ? "全部" : warehouseCode + "-" + count + "张";
var pdfRequest = new PrintPdfClient.InboundMarkPdfRequest("INBOUND_MARK_V1",
new PrintPdfClient.PageSpec(PAGE_MM, PAGE_MM), manifest.containerNo() + "-托盘码-" + suffix + ".pdf", labels);
try { return requirePdf(printClient.generateInboundMarks(internalToken, requestId, pdfRequest)); }
catch (FeignException | NoFallbackAvailableException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试"); }
}
private List<LabelRow> expected(SourceRow plan, List<SourceRow> sources, String containerNo) {
Map<String, Integer> indexes = new LinkedHashMap<>();
List<LabelRow> result = new ArrayList<>();
for (SourceRow source : sources) {
String code = source.getDestinationWarehouseCode().trim().toUpperCase(Locale.ROOT);
for (int local = 0; local < source.getPalletCount(); local++) {
int index = indexes.merge(code, 1, Integer::sum);
if (index > 999) throw new BizException(ResultCode.CONFLICT, "单柜同一 FBA 仓托盘数不能超过 999");
LabelRow row = new LabelRow(); row.setLabelId(IdWorker.getId()); row.setPlanId(plan.getPlanId());
row.setItemId(source.getItemId()); row.setAccountId(source.getAccountId()); row.setInboundId(source.getInboundId());
row.setInboundCode(source.getInboundCode()); row.setContainerNo(containerNo);
row.setDestinationWarehouseCode(code); row.setDestinationWarehouseName(source.getDestinationWarehouseName());
row.setPalletIndex(index); row.setPalletCode(containerNo + "-" + code + "-" + String.format("%03d", index));
if (row.getPalletCode().length() > 128) throw new BizException(ResultCode.CONFLICT, "托盘码长度超过系统限制");
result.add(row);
}
}
return result;
}
private void requireSame(List<LabelRow> expected, List<LabelRow> actual) {
List<String> left = expected.stream().map(this::identity).toList();
List<String> right = actual.stream().map(this::identity).toList();
if (!left.equals(right)) throw new BizException(ResultCode.CONFLICT, "已生成的托盘码与当前装柜明细不一致");
}
private String identity(LabelRow row) { return row.getItemId()+"|"+row.getPalletCode(); }
private PalletLabelManifest manifest(SourceRow plan, List<LabelRow> rows) {
Map<String, List<LabelRow>> grouped = rows.stream().collect(java.util.stream.Collectors.groupingBy(
LabelRow::getDestinationWarehouseCode, LinkedHashMap::new, java.util.stream.Collectors.toList()));
List<PalletLabelGroup> groups = grouped.values().stream().map(values -> new PalletLabelGroup(
values.get(0).getDestinationWarehouseCode(), values.get(0).getDestinationWarehouseName(), values.size(),
values.stream().map(this::label).toList())).toList();
String containerNo = rows.isEmpty() ? plan.getContainerNo() : rows.get(0).getContainerNo();
return new PalletLabelManifest(plan.getPlanId(), plan.getPlanNo(), containerNo, rows.size(), groups);
}
private PalletLabel label(LabelRow row) { return new PalletLabel(row.getLabelId(), row.getItemId(), row.getAccountId(), row.getInboundId(), row.getInboundCode(), row.getContainerNo(), row.getDestinationWarehouseCode(), row.getDestinationWarehouseName(), row.getPalletIndex(), row.getPalletCode()); }
private static String normalize(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private ResponseEntity<byte[]> requirePdf(ResponseEntity<byte[]> response) {
byte[] body = response == null || response.getBody() == null ? new byte[0] : response.getBody();
MediaType type = response == null ? null : response.getHeaders().getContentType();
if (type == null || !MediaType.APPLICATION_PDF.isCompatibleWith(type) || body.length == 0) {
try { ApiResponse<Object> error = objectMapper.readValue(body, new TypeReference<>() { });
if (error != null && !error.successful()) throw new BizException(ResultCode.of(error.code()), error.message());
} catch (BizException exception) { throw exception; } catch (Exception ignored) { }
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务返回了无效响应");
}
HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_PDF);
String disposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
if (StringUtils.hasText(disposition)) headers.set(HttpHeaders.CONTENT_DISPOSITION, disposition);
headers.setContentLength(body.length); return ResponseEntity.ok().headers(headers).body(body);
}
}
@@ -20,11 +20,10 @@ public class TerminalDrayageMailDispatcher {
@Value("${baiyue.terminal-drayage.mail.port:587}") private int port;
@Value("${baiyue.terminal-drayage.mail.username}") private String username;
@Value("${baiyue.terminal-drayage.mail.password}") private String password;
@Value("${baiyue.terminal-drayage.mail.from}") private String from;
@Value("${baiyue.terminal-drayage.mail.ssl:false}") private boolean ssl;
JavaMailSenderImpl sender() {
TerminalDrayageMailService.validateAddress(from);
TerminalDrayageMailService.validateAddress(username);
var sender = new JavaMailSenderImpl();
sender.setHost(host); sender.setPort(port); sender.setUsername(username); sender.setPassword(password);
sender.setDefaultEncoding("UTF-8");
@@ -47,7 +46,7 @@ public class TerminalDrayageMailDispatcher {
if (mapper.claimMail(mail.id()) != 1) continue;
try {
var message = new SimpleMailMessage();
message.setFrom(from); message.setTo(mail.recipient());
message.setFrom(username); message.setTo(mail.recipient());
message.setSubject(mail.subject()); message.setText(mail.body());
sender().send(message);
if (mapper.completeMail(mail.id()) != 1) log.warn("Terminal drayage mail completion requires review: {}", mail.id());
@@ -34,11 +34,12 @@ public class TerminalDrayageMailService {
public void replaceFleet(long containerId, String containerNo, long oldSupplierId, long newSupplierId, long operatorId) {
var next = contact(newSupplierId, true);
// 取消通知发往原请求的收件地址;尚未发过请求则读取原车队资料,允许原车队已停用。
// 请求邮件记录就是当前车队是否已下发的事实;未下发过时不能向原车队发送无来源的取消通知。
MailTask previous = mapper.lastPickupMail(containerId, oldSupplierId);
var old = previous == null ? contact(oldSupplierId, false)
: new WarehouseSupplierClient.TruckingContact(oldSupplierId, previous.fleetName(), previous.recipient());
enqueue(containerId, containerNo, oldSupplierId, old.name(), old.email(), "CANCEL_PICKUP", operatorId);
if (previous != null) {
enqueue(containerId, containerNo, oldSupplierId, previous.fleetName(), previous.recipient(),
"CANCEL_PICKUP", operatorId);
}
enqueue(containerId, containerNo, newSupplierId, next.name(), next.email(), "REQUEST_PICKUP", operatorId);
}
@@ -37,7 +37,6 @@ baiyue:
port: ${TERMINAL_DRAYAGE_MAIL_PORT:587}
username: ${TERMINAL_DRAYAGE_MAIL_USERNAME:}
password: ${TERMINAL_DRAYAGE_MAIL_PASSWORD:}
from: ${TERMINAL_DRAYAGE_MAIL_FROM:}
ssl: ${TERMINAL_DRAYAGE_MAIL_SSL:false}
scan-delay: ${TERMINAL_DRAYAGE_MAIL_SCAN_DELAY:10000}
carrier-schedule:
@@ -354,6 +354,29 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1))
) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层';
CREATE TABLE IF NOT EXISTS fl_loading_pallet_label (
id BIGINT UNSIGNED NOT NULL COMMENT '散货托盘标签ID(雪花ID)',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID',
loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '标签归属装柜明细ID',
account_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID快照',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入仓单ID快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
container_no VARCHAR(64) NOT NULL COMMENT '真实集装箱号快照',
destination_warehouse_code VARCHAR(64) NOT NULL COMMENT 'FBA仓编码快照',
destination_warehouse_name VARCHAR(128) NULL COMMENT 'FBA仓名称快照',
pallet_index INT UNSIGNED NOT NULL COMMENT '当前柜当前FBA仓内从1开始的序号',
pallet_code VARCHAR(128) NOT NULL COMMENT '托盘码:集装箱号-FBA仓编码-三位序号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '生成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pallet_code (pallet_code),
UNIQUE KEY uk_loading_pallet_index (loading_plan_id,destination_warehouse_code,pallet_index),
KEY idx_loading_pallet_item (loading_plan_item_id,pallet_index),
KEY idx_loading_pallet_inbound (account_id,inbound_id),
CONSTRAINT fk_loading_pallet_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan(id),
CONSTRAINT fk_loading_pallet_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item(id),
CONSTRAINT chk_loading_pallet_index CHECK (pallet_index BETWEEN 1 AND 999)
) ENGINE=InnoDB COMMENT='散货按柜号和FBA仓冻结的可打印托盘码';
-- 柜次级简单文件仅承载目的港清关和提货单;PU资料作为数字码保存在货柜主表。
CREATE TABLE IF NOT EXISTS fl_container_document (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
@@ -74,6 +74,23 @@ paths:
content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmPreloadRequest'}}}
responses:
'200': {$ref: '#/components/responses/PreloadConfirmationResponse'}
/api/platform/v1/first-leg/loading-plans/{planId}/pallet-labels/prepare:
post:
summary: 生成并查询散货托盘码清单
description: 预装确认且已有真实柜号后,按柜号-FBA仓编码-三位序号生成稳定清单;一件代发不参与。
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}]
responses:
'200': {description: 按 FBA 仓分组的散货托盘码清单, content: {application/json: {schema: {type: object}}}}
/api/platform/v1/first-leg/loading-plans/{planId}/pallet-labels/print:
post:
summary: 打印散货托盘码
description: 仓码和张数均为空时打印全部;均有值时打印该 FBA 仓从第1张开始的指定数量,用于补打。
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}]
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/PalletLabelPrintRequest'}}}
responses:
'200': {description: 100mm × 100mm 托盘码 PDF, content: {application/pdf: {schema: {type: string, format: binary}}}}
/api/platform/v1/first-leg/loading-lists:
get:
summary: 分页查询装柜清单
@@ -430,3 +447,9 @@ components:
voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次}
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本}
operationReason: {type: string, minLength: 1, maxLength: 500, nullable: true, description: 纠正已有真实集装箱号时必填;首次补录或保持当前柜号时可省略}
PalletLabelPrintRequest:
type: object
additionalProperties: false
properties:
destinationWarehouseCode: {type: string, maxLength: 64, nullable: true}
count: {type: integer, minimum: 1, maximum: 999, nullable: true}
@@ -55,7 +55,7 @@ paths:
/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-email:
post:
summary: 将当前车队提柜邮件加入发送队列
description: 初次选择车队不发信;此命令携带版本和幂等键,发送成功是任务已入队,不表示已送达。首次更换车队自动原子入队取消和新提柜两封邮件。同一车队重复选择不发信。
description: 初次选择车队不发信;此命令携带版本和幂等键,发送成功是任务已入队,不表示已送达。人工可使用最新版本重复发送提柜请求。更换车队时,原车队存在提柜请求邮件记录才原子入队取消邮件,并始终为新车队入队提柜请求;原车队从未下发时不发送取消邮件。同一车队重复选择不发信。
x-permission: platform:firstleg:terminal-drayage:fleet:edit
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}}}}}}
@@ -23,6 +23,8 @@ class FbaPricingContractTest {
+ " 'firstleg:fba-pricing:confirm')");
assertPricingAuthority("send", "hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
+ " 'firstleg:inbound:send-fee')");
assertPricingAuthority("adjustPublished", "hasAnyAuthority('firstleg:fba-pricing:adjust',"
+ " 'firstleg:inbound:confirm-send-fee')");
}
@Test
@@ -75,6 +75,9 @@ class LoadingListControllerContractTest {
.contains("PDA任务尚未领取且无任何物理作业事实")
.contains("/loading-plans/{planId}/confirm")
.contains("/loading-plans/{planId}/lifecycle")
.contains("/loading-plans/{planId}/pallet-labels/prepare")
.contains("/loading-plans/{planId}/pallet-labels/print")
.contains("柜号-FBA仓编码-三位序号")
.contains("不得以错误文案或 A0404 猜测阶段")
.contains("confirmationStatus")
.contains("canConfirm")
@@ -123,6 +126,8 @@ class LoadingListControllerContractTest {
"/api/platform/v1/first-leg/loading-plans/{planId}/lifecycle",
"/api/platform/v1/first-leg/loading-plans/{planId}/transport",
"/api/platform/v1/first-leg/loading-plans/{planId}/confirm",
"/api/platform/v1/first-leg/loading-plans/{planId}/pallet-labels/prepare",
"/api/platform/v1/first-leg/loading-plans/{planId}/pallet-labels/print",
"/api/platform/v1/first-leg/loading-lists",
"/api/platform/v1/first-leg/loading-lists/{planId}");
Map<String, Object> components = stringMap(document.get("components"));
@@ -132,7 +137,7 @@ class LoadingListControllerContractTest {
"ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail",
"ConfirmedDetail", "TransportUpdateRequest", "PreloadReadiness",
"ConfirmPreloadRequest", "PreloadConfirmation", "PreloadConfirmationEnvelope",
"LifecycleResolution", "LifecycleResolutionEnvelope");
"LifecycleResolution", "LifecycleResolutionEnvelope", "PalletLabelPrintRequest");
assertThat(properties(schemas, "LifecycleResolution")).containsOnlyKeys(
"planId", "containerBookingId", "warehouseId", "planNo", "planStatus",
"detailResource", "requiredPermission");
@@ -9,6 +9,7 @@ import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -30,14 +31,19 @@ class LoadingCompletedOutboxServiceTest {
private final LoadingCompletedEventMapper snapshots = mock(LoadingCompletedEventMapper.class);
private final InboundOutboxMapper outbox = mock(InboundOutboxMapper.class);
private final LoadingCartonManifestMapper manifests = mock(LoadingCartonManifestMapper.class);
private final LoadingPalletLabelMapper palletLabels = mock(LoadingPalletLabelMapper.class);
private final LoadingPalletLabelService palletLabelService = mock(LoadingPalletLabelService.class);
private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
private final LoadingCompletedOutboxService service = new LoadingCompletedOutboxService();
@BeforeEach void setup() {
ReflectionTestUtils.setField(service,"snapshotMapper",snapshots);
ReflectionTestUtils.setField(service,"cartonManifestMapper",manifests);
ReflectionTestUtils.setField(service,"palletLabelMapper",palletLabels);
ReflectionTestUtils.setField(service,"palletLabelService",palletLabelService);
ReflectionTestUtils.setField(service,"outboxMapper",outbox);
ReflectionTestUtils.setField(service,"objectMapper",mapper);
when(palletLabels.labels(anyLong())).thenReturn(List.of());
when(manifests.labelPool(anyLong(), anyLong())).thenAnswer(invocation -> List.of(
label(101, "BOX-101"), label(102, "BOX-102"),
label(202, "BOX-202"), label(302, "BOX-302")));
@@ -0,0 +1,46 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class LoadingPalletLabelServiceTest {
@Test
void generatesStableIndexesPerContainerAndFbaAndSkipsDropship() {
LoadingPalletLabelMapper mapper = mock(LoadingPalletLabelMapper.class);
LoadingPalletLabelService service = new LoadingPalletLabelService();
ReflectionTestUtils.setField(service, "mapper", mapper);
List<LoadingPalletLabelMapper.LabelRow> saved = new ArrayList<>();
when(mapper.lockSources(10L)).thenReturn(List.of(source(3, "LAX9", 1, "STANDARD"),
source(1, "ONT8", 2, "STANDARD"), source(2, "ONT8", 1, "STANDARD"),
source(4, "ONT8", 4, "ONE_CLICK_DROPSHIP")));
when(mapper.labels(10L)).thenAnswer(ignored -> List.copyOf(saved));
when(mapper.insert(any())).thenAnswer(invocation -> { saved.add(invocation.getArgument(0)); return 1; });
var manifest = service.prepare(10L, 20L);
assertThat(manifest.totalCount()).isEqualTo(4);
assertThat(manifest.groups()).extracting(group -> group.destinationWarehouseCode() + ":" + group.totalCount())
.containsExactly("LAX9:1", "ONT8:3");
assertThat(saved).extracting(LoadingPalletLabelMapper.LabelRow::getPalletCode)
.containsExactly("MSCU6639870-LAX9-001", "MSCU6639870-ONT8-001",
"MSCU6639870-ONT8-002", "MSCU6639870-ONT8-003");
}
private LoadingPalletLabelMapper.SourceRow source(long itemId, String destination, int pallets, String fulfillment) {
var row = new LoadingPalletLabelMapper.SourceRow(); row.setPlanId(10L); row.setPlanNo("LP-1");
row.setPlanStatus("LOADING"); row.setContainerNo("MSCU6639870"); row.setWarehouseId(20L);
row.setItemId(itemId); row.setAccountId(100L); row.setInboundId(itemId + 1000);
row.setInboundCode("RK-" + itemId); row.setDestinationWarehouseCode(destination);
row.setDestinationWarehouseName(destination); row.setPalletCount(pallets);
row.setFulfillmentType(fulfillment); row.setDestinationType("FBA"); return row;
}
}
@@ -7,6 +7,7 @@ import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
@@ -16,7 +17,7 @@ class TerminalDrayageMailDispatcherTest {
TerminalDrayageMailDispatcher worker = spy(new TerminalDrayageMailDispatcher(mapper));
void setup() {
ReflectionTestUtils.setField(worker,"from","operations@example.com");
ReflectionTestUtils.setField(worker,"username","operations@example.com");
doReturn(sender).when(worker).sender();
when(mapper.pendingMails()).thenReturn(List.of(new TerminalDrayageMapper.MailTask(
1L,11L,2L,"Fleet","fleet@example.com","REQUEST_PICKUP","Pickup","Body","PENDING",0,null,null,null)));
@@ -27,8 +28,10 @@ class TerminalDrayageMailDispatcherTest {
@Test void onlyClaimsOnceAndMarksSentAfterSmtpAccepted() {
setup(); worker.dispatch();
var ordered = inOrder(mapper,sender);
var sent = org.mockito.ArgumentCaptor.forClass(SimpleMailMessage.class);
ordered.verify(mapper).claimMail(1L);
ordered.verify(sender).send(any(SimpleMailMessage.class));
ordered.verify(sender).send(sent.capture());
assertThat(sent.getValue().getFrom()).isEqualTo("operations@example.com");
ordered.verify(mapper).completeMail(1L);
when(mapper.claimMail(1L)).thenReturn(0);
worker.dispatch();
@@ -27,6 +27,8 @@ class TerminalDrayageMailServiceTest {
}
@Test void replacementQueuesCancellationThenRequestWithSeparateFrozenRecipients() {
when(mapper.lastPickupMail(11L, 1L)).thenReturn(new TerminalDrayageMapper.MailTask(
90L,11L,1L,"Old Fleet","old@example.com","REQUEST_PICKUP","subject","body","SENT",1,null,null,null));
service.replaceFleet(11L, "MSCU1234567", 1L, 2L, 7L);
var order = inOrder(mapper);
order.verify(mapper).insertMail(anyLong(), eq(11L), eq(1L), eq("Old Fleet"), eq("old@example.com"),
@@ -35,6 +37,16 @@ class TerminalDrayageMailServiceTest {
eq("REQUEST_PICKUP"), eq("Port pickup request - MSCU1234567"), contains("Please arrange"), eq(7L));
}
@Test void replacementWithoutPreviousPickupMailQueuesOnlyNewRequest() {
service.replaceFleet(11L, "MSCU1234567", 1L, 2L, 7L);
verify(mapper, never()).insertMail(anyLong(), eq(11L), eq(1L), anyString(), anyString(),
eq("CANCEL_PICKUP"), anyString(), anyString(), eq(7L));
verify(mapper).insertMail(anyLong(), eq(11L), eq(2L), eq("New Fleet"), eq("new@example.com"),
eq("REQUEST_PICKUP"), eq("Port pickup request - MSCU1234567"), contains("Please arrange"), eq(7L));
verify(suppliers, never()).truckingContact("test-token", 1L, false);
}
@Test void cancellationUsesOriginalRecipientEvenIfSupplierEmailChanged() {
when(mapper.lastPickupMail(11L, 1L)).thenReturn(new TerminalDrayageMapper.MailTask(
90L,11L,1L,"Frozen Fleet","original@example.com","REQUEST_PICKUP","subject","body","SENT",1,null,null,null));
@@ -374,18 +374,23 @@ class TerminalDrayageServiceTest {
}
@Test
void manualPickupEmailUsesLockedVersionAndRejectsStaleClick() {
void manualPickupEmailRejectsStaleClickAndAllowsRepeatWithLatestVersion() {
Context context = context("ARRIVED_DESTINATION_PORT",null);
when(mapper.lockContext(11L)).thenReturn(context);
when(mapper.context(11L)).thenReturn(context);
TerminalDrayageEntity row = row(3);
when(mapper.lock(11L)).thenReturn(row);
when(mapper.find(11L)).thenReturn(row);
when(mapper.updateAggregate(any(),anyInt(),anyLong(),anyString())).thenAnswer(invocation -> {row.setVersion(4);return 1;});
when(mapper.updateAggregate(any(),anyInt(),anyLong(),anyString())).thenAnswer(invocation -> {
row.setVersion(invocation.getArgument(1, Integer.class) + 1);
return 1;
});
var request = new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest(3);
service.sendPickupEmail(11L,7L,"admin","mail-1",request);
assertThatThrownBy(() -> service.sendPickupEmail(11L,7L,"admin","mail-2",request)).isInstanceOf(BizException.class);
verify(mails,times(1)).request(11L,"MSCU1234567",91L,7L);
service.sendPickupEmail(11L,7L,"admin","mail-3",
new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest(4));
verify(mails,times(2)).request(11L,"MSCU1234567",91L,7L);
}
private Context context(String node, LocalDateTime unloadedAt) {