This commit is contained in:
2026-08-13 17:14:54 +08:00
59 changed files with 1609 additions and 96 deletions
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.EventEnvelope;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
/** 将本地业务 Outbox 投递到跨区事件桥。 */
@FeignClient(name = "event-bridge-service", contextId = "loadingCompletedEventBridgeClient")
public interface EventBridgeClient {
@PostMapping("/internal/event-bridge/events")
ApiResponse<Acknowledgement> receive(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody EventEnvelope envelope);
record Acknowledgement(String eventId, String result, String receivedAt) { }
}
@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
import java.math.BigDecimal;
import java.util.List;
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
@FeignClient(name = "order-service", contextId = "firstlegOrderCargoClient",
url = "${baiyue.clients.order.url:}", path = "/internal/order/v1/cargoes")
@@ -24,6 +25,12 @@ public interface OrderCargoClient {
@PathVariable long tenantId,
@PathVariable long orderId);
@GetMapping("/{tenantId}/{orderId}/inbound-snapshot")
ApiResponse<OrderApprovedInboundCommand> inboundSnapshot(
@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable long orderId);
record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn,
@@ -62,6 +62,33 @@ public class InternalInboundOrderController {
return ApiResponse.success(service.createFromApprovedOrder(command));
}
@PostMapping("/order-preparing")
public ApiResponse<Long> prepareFromDraft(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody OrderApprovedInboundCommand command) {
requireToken(token);
requireSnapshot(command);
return ApiResponse.success(service.prepareFromDraft(command));
}
@PostMapping("/order-preparing/batch")
public ApiResponse<List<InboundOrderReference.Item>> prepareFromDraftBatch(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody List<OrderApprovedInboundCommand> commands) {
requireToken(token);
if (commands == null || commands.isEmpty() || commands.size() > 200) {
throw new BizException(ResultCode.BAD_REQUEST, "预入库订单批次必须为1至200条");
}
commands.forEach(this::requireSnapshot);
long tenantId = commands.get(0).tenantId();
if (commands.stream().anyMatch(command -> command.tenantId() != tenantId)) {
throw new BizException(ResultCode.BAD_REQUEST, "预入库订单批次必须属于同一客户");
}
commands.forEach(service::prepareFromDraft);
return ApiResponse.success(service.findReferences(tenantId,
commands.stream().map(OrderApprovedInboundCommand::orderId).distinct().toList()));
}
@PostMapping("/order-references")
public ApiResponse<List<InboundOrderReference.Item>> references(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -101,4 +128,17 @@ public class InternalInboundOrderController {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
}
private void requireSnapshot(OrderApprovedInboundCommand command) {
if (command == null || command.tenantId() <= 0 || command.orderId() <= 0
|| command.orderNo() == null || command.orderNo().isBlank()
|| command.inboundCode() == null || command.inboundCode().isBlank()
|| command.inboundCode().length() > 40
|| command.customerName() == null || command.customerName().isBlank()
|| command.warehouseId() == null || command.warehouseId() <= 0
|| command.warehouseName() == null || command.warehouseName().isBlank()
|| command.businessType() == null || command.businessType().isBlank()) {
throw new BizException(ResultCode.BAD_REQUEST, "草稿预入库快照不完整");
}
}
}
@@ -0,0 +1,67 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.InboundCartonLabelPrintService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.constraints.Min;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
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.web.bind.annotation.RequestBody;
import java.util.List;
/** 客户端下载本客户入库单箱唛。 */
@Tag(name = "客户端入库箱唛")
@Validated
@RestController
@RequestMapping("/api/merchant/v1/first-leg")
public class MerchantInboundCartonLabelController {
@Resource
private InboundCartonLabelPrintService cartonLabelPrintService;
@Operation(summary = "生成入仓箱唛 PDF")
@PostMapping("/inbounds/{inboundId}/carton-label-pdf")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ResponseEntity<byte[]> print(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Min(1) long inboundId,
@RequestParam(required = false) List<@Min(1) Integer> cartonIndex,
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
return cartonLabelPrintService.generate(
FirstlegScopeGuard.merchant(user), inboundId, cartonIndex, requestId);
}
@Operation(summary = "按订单生成入仓箱唛 PDF")
@PostMapping("/orders/{orderId}/carton-label-pdf")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ResponseEntity<byte[]> printByOrder(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Min(1) long orderId,
@RequestParam(required = false) List<@Min(1) Integer> cartonIndex,
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
return cartonLabelPrintService.generateByOrder(
FirstlegScopeGuard.merchant(user), orderId, cartonIndex, requestId);
}
@Operation(summary = "批量按订单生成箱唛 PDF")
@PostMapping("/orders/carton-label-pdf")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ResponseEntity<byte[]> printByOrders(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestBody List<Long> orderIds,
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
return cartonLabelPrintService.generateByOrders(
FirstlegScopeGuard.merchant(user), orderIds, requestId);
}
}
@@ -6,6 +6,8 @@ import java.util.Set;
public final class InboundProcessStatus {
/** 等待录入或接收测量数据。 */
public static final String WAIT_MEASURE = "WAIT_MEASURE";
/** 草稿预入库:允许生成箱唛,但尚未进入正式测量流程。 */
public static final String PREPARING = "PREPARING";
/** 已存在测量草稿、重量 OCR 或人工修正,尚未提交审核。 */
public static final String MEASURING = "MEASURING";
/** 测量已提交,等待平台审核。 */
@@ -30,7 +32,7 @@ public final class InboundProcessStatus {
public static final String VOIDED = "VOIDED";
public static final Set<String> VALUES = Set.of(
WAIT_MEASURE, MEASURING, MEASURE_PENDING, MEASURE_REJECTED,
PREPARING, WAIT_MEASURE, MEASURING, MEASURE_PENDING, MEASURE_REJECTED,
WAIT_PRICING, FEE_CONFIRMING, INBOUNDING, WAIT_SPLIT,
LOADING, LOADED, DELIVERED, VOIDED
);
@@ -17,4 +17,5 @@ public final class InboundOrderReference {
public record Item(Long orderId, Long inboundId, String inboundCode, String processStatus) {
}
}
@@ -27,6 +27,10 @@ public class InboundCartonLabelEntity {
private String boxCode;
private Integer totalCartonCount;
private Integer cartonIndex;
private Integer labelVersion;
private String batchStatus;
private String cargoFingerprint;
private LocalDateTime voidedAt;
private String boxSpecification;
private BigDecimal lengthCm;
private BigDecimal widthCm;
@@ -0,0 +1,53 @@
package com.baiyue.firstleg.mapper;
import lombok.Getter;
import lombok.Setter;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.util.List;
/** 读取 PDA 结算后的最终实装入仓单和冻结货物快照。 */
@Mapper
public interface LoadingCompletedEventMapper {
@Select("""
SELECT p.id AS loading_plan_id, p.plan_no, p.container_booking_id, p.version AS aggregate_version,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
i.account_id, i.inbound_id AS source_inbound_id,
COALESCE(o.root_inbound_id, o.id) AS root_inbound_id,
o.order_id AS source_order_id, o.order_no AS source_order_no,
COALESCE(root_order.order_id, o.order_id) AS root_order_id,
o.inbound_code, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code,
o.destination_name AS destination_warehouse_name,
i.inbound_detail_id AS source_inbound_detail_id, i.sku_code,
i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity,
i.pallet_count, i.gross_weight_kg, i.net_weight_kg, i.dimensional_weight_kg,
i.volume_cbm, i.fba_tracking_no, i.shipment_no, i.shipping_mark
FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id = p.container_booking_id
JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0
JOIN fl_inbound_order o ON o.id = i.inbound_id AND o.tenant_id = i.account_id AND o.deleted = 0
LEFT JOIN fl_inbound_order root_order ON root_order.id = COALESCE(o.root_inbound_id, o.id)
WHERE p.id = #{planId} AND p.plan_status = 'COMPLETED'
AND i.package_count > 0
ORDER BY i.account_id, i.inbound_id, i.inbound_detail_id, i.id
""")
List<Row> rows(@Param("planId") long planId);
@Getter @Setter
class Row {
private Long loadingPlanId; private String planNo; private Long containerBookingId;
private Integer aggregateVersion; private String containerNo; private String cabinetNo;
private Long warehouseId; private String warehouseName; private Long accountId;
private Long sourceInboundId; private Long rootInboundId; private Long sourceOrderId;
private String sourceOrderNo; private Long rootOrderId; private String inboundCode;
private Long destinationWarehouseId; private String destinationWarehouseCode;
private String destinationWarehouseName; private Long sourceInboundDetailId; private String skuCode;
private String productNameZh; private String productNameEn; private Integer packageCount;
private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg;
private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm;
private String fbaTrackingNo; private String shipmentNo; private String shippingMark;
}
}
@@ -17,9 +17,10 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
label.box_code AS boxCode
FROM fl_loading_pick_item pick_item
JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id
JOIN fl_inbound_carton_label label ON label.tenant_id = plan_item.account_id
JOIN fl_inbound_carton_label label ON label.tenant_id = plan_item.account_id
AND label.inbound_id = plan_item.inbound_id
AND label.inbound_detail_id = plan_item.inbound_detail_id
AND label.batch_status = 'ACTIVE'
WHERE pick_item.pick_task_id = #{taskId} AND plan_item.deleted = 0
ORDER BY pick_item.id, label.carton_index
""")
@@ -24,19 +24,63 @@ public class InboundCartonLabelRepository {
.last("FOR UPDATE"));
}
public List<InboundCartonLabelEntity> findByInbound(long tenantId, long inboundId) {
public InboundOrderEntity findByOrder(long tenantId, long orderId) {
return inbounds.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(InboundOrderEntity::getOrderId, orderId)
.eq(InboundOrderEntity::getDeleted, false)
.orderByDesc(InboundOrderEntity::getCreatedAt)
.last("LIMIT 1"));
}
public List<InboundCartonLabelEntity> findActiveByInbound(long tenantId, long inboundId) {
return labels.selectList(new LambdaQueryWrapper<InboundCartonLabelEntity>()
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
.eq(InboundCartonLabelEntity::getInboundId, inboundId)
.eq(InboundCartonLabelEntity::getBatchStatus, "ACTIVE")
.orderByAsc(InboundCartonLabelEntity::getCartonIndex));
}
/** Compatibility for older callers; new code must use the ACTIVE-only query. */
public List<InboundCartonLabelEntity> findByInbound(long tenantId, long inboundId) {
return findActiveByInbound(tenantId, inboundId);
}
public InboundCartonLabelEntity findByBoxCode(long tenantId, String boxCode) {
return labels.selectOne(new LambdaQueryWrapper<InboundCartonLabelEntity>()
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
.eq(InboundCartonLabelEntity::getBoxCode, boxCode));
}
public int nextVersion(long tenantId, long inboundId) {
InboundCartonLabelEntity latest = labels.selectOne(new LambdaQueryWrapper<InboundCartonLabelEntity>()
.select(InboundCartonLabelEntity::getLabelVersion)
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
.eq(InboundCartonLabelEntity::getInboundId, inboundId)
.orderByDesc(InboundCartonLabelEntity::getLabelVersion)
.last("LIMIT 1"));
return latest == null || latest.getLabelVersion() == null ? 1 : latest.getLabelVersion() + 1;
}
public void voidActive(long tenantId, long inboundId, java.time.LocalDateTime at) {
labels.update(null, new LambdaUpdateWrapper<InboundCartonLabelEntity>()
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
.eq(InboundCartonLabelEntity::getInboundId, inboundId)
.eq(InboundCartonLabelEntity::getBatchStatus, "ACTIVE")
.set(InboundCartonLabelEntity::getBatchStatus, "VOID")
.set(InboundCartonLabelEntity::getVoidedAt, at));
}
public void initializeLegacyFingerprint(long tenantId, long inboundId, int version, String fingerprint) {
labels.update(null, new LambdaUpdateWrapper<InboundCartonLabelEntity>()
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
.eq(InboundCartonLabelEntity::getInboundId, inboundId)
.eq(InboundCartonLabelEntity::getLabelVersion, version)
.eq(InboundCartonLabelEntity::getBatchStatus, "ACTIVE")
.isNull(InboundCartonLabelEntity::getCargoFingerprint)
.set(InboundCartonLabelEntity::getCargoFingerprint, fingerprint));
}
public boolean initializeOriginalPackageCount(long tenantId, long inboundId, int packageCount) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
@@ -46,6 +90,14 @@ public class InboundCartonLabelRepository {
.setSql("version = version + 1")) == 1;
}
public void updatePackageCount(long tenantId, long inboundId, int packageCount) {
inbounds.update(null, new LambdaUpdateWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(InboundOrderEntity::getId, inboundId)
.set(InboundOrderEntity::getTotalPackageCount, packageCount)
.setSql("version = version + 1"));
}
public void insert(InboundCartonLabelEntity label) {
labels.insert(label);
}
@@ -0,0 +1,45 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.common.event.EventTypes;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public class LoadingCompletedOutboxRepository {
@Resource private InboundOutboxMapper mapper;
public List<InboundOutboxEntity> pending(int limit, LocalDateTime now) {
return mapper.selectList(new LambdaQueryWrapper<InboundOutboxEntity>()
.eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
.and(v -> v.isNull(InboundOutboxEntity::getNextRetryAt).or().le(InboundOutboxEntity::getNextRetryAt, now))
.orderByAsc(InboundOutboxEntity::getCreatedAt).orderByAsc(InboundOutboxEntity::getId)
.last("LIMIT " + Math.min(Math.max(limit, 1), 100)));
}
public boolean claim(long id, String worker, LocalDateTime now) { return mapper.update(null, base(id, "PENDING")
.set(InboundOutboxEntity::getPublishStatus, "PROCESSING").set(InboundOutboxEntity::getLockedBy, worker)
.set(InboundOutboxEntity::getLockedAt, now)) == 1; }
public InboundOutboxEntity claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper<InboundOutboxEntity>()
.eq(InboundOutboxEntity::getId, id).eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker)); }
public void published(long id, String worker, LocalDateTime now) { mapper.update(null, claimedBase(id, worker)
.set(InboundOutboxEntity::getPublishStatus, "PUBLISHED").set(InboundOutboxEntity::getPublishedAt, now)
.set(InboundOutboxEntity::getLockedBy, null).set(InboundOutboxEntity::getLockedAt, null)
.set(InboundOutboxEntity::getLastError, null)); }
public void retry(long id, String worker, LocalDateTime retryAt, String error) { mapper.update(null, claimedBase(id, worker)
.setSql("retry_count = retry_count + 1").set(InboundOutboxEntity::getPublishStatus, "PENDING")
.set(InboundOutboxEntity::getNextRetryAt, retryAt).set(InboundOutboxEntity::getLockedBy, null)
.set(InboundOutboxEntity::getLockedAt, null).set(InboundOutboxEntity::getLastError, truncate(error))); }
private LambdaUpdateWrapper<InboundOutboxEntity> base(long id, String status) { return new LambdaUpdateWrapper<InboundOutboxEntity>()
.eq(InboundOutboxEntity::getId,id).eq(InboundOutboxEntity::getEventType,EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
.eq(InboundOutboxEntity::getPublishStatus,status); }
private LambdaUpdateWrapper<InboundOutboxEntity> claimedBase(long id,String worker) { return base(id,"PROCESSING").eq(InboundOutboxEntity::getLockedBy,worker); }
private static String truncate(String value) { if(value==null)return "unknown"; String v=value.replaceAll("[\\r\\n\\t]+"," "); return v.length()>500?v.substring(0,500):v; }
}
@@ -9,6 +9,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
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;
@@ -17,6 +18,7 @@ import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.util.List;
import java.util.ArrayList;
/** Allocates stable carton codes and forwards immutable label facts to Print. */
@Service
@@ -50,7 +52,53 @@ public class InboundCartonLabelPrintService {
batch.labels().stream().map(this::toPrintLabel).toList());
try {
return requirePdf(printClient.generateInboundMarks(internalToken, requestId, request));
} catch (FeignException exception) {
} catch (FeignException | NoFallbackAvailableException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试");
}
}
public ResponseEntity<byte[]> generateByOrder(long tenantId, long orderId,
List<Integer> cartonIndexes, String requestId) {
return generate(tenantId, labelService.requireInboundIdByOrder(tenantId, orderId),
cartonIndexes, requestId);
}
public ResponseEntity<byte[]> generateByOrders(long tenantId, List<Long> orderIds, String requestId) {
if (orderIds == null || orderIds.isEmpty() || orderIds.size() > 200
|| orderIds.stream().anyMatch(id -> id == null || id <= 0)) {
throw new BizException(ResultCode.BAD_REQUEST, "订单ID数组必须包含1至200个有效值");
}
List<InboundCartonLabels.Label> labels = new ArrayList<>();
List<String> failures = new ArrayList<>();
for (Long orderId : orderIds.stream().distinct().toList()) {
try {
long inboundId = labelService.requireInboundIdByOrder(tenantId, orderId);
labels.addAll(labelService.list(tenantId, inboundId, null).labels());
} catch (BizException exception) {
failures.add(orderId + ":" + exception.getResultCode().code());
}
}
if (labels.isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED,
failures.isEmpty() ? "没有可生成箱唛的订单" : "所选订单均无法生成箱唛:" + String.join(",", failures));
}
ResponseEntity<byte[]> response = generatePdf("carton-labels-" + labels.size() + ".pdf", labels, requestId);
HttpHeaders headers = new HttpHeaders();
headers.putAll(response.getHeaders());
headers.set("X-Carton-Label-Order-Count", String.valueOf(orderIds.stream().distinct().count()));
headers.set("X-Carton-Label-Failure-Count", String.valueOf(failures.size()));
if (!failures.isEmpty()) headers.set("X-Carton-Label-Failures", String.join(",", failures));
return ResponseEntity.status(response.getStatusCode()).headers(headers).body(response.getBody());
}
private ResponseEntity<byte[]> generatePdf(String fileName, List<InboundCartonLabels.Label> labels,
String requestId) {
PrintPdfClient.InboundMarkPdfRequest request = new PrintPdfClient.InboundMarkPdfRequest(
"INBOUND_MARK_V1", new PrintPdfClient.PageSpec(PAGE_MM, PAGE_MM), fileName,
labels.stream().map(this::toPrintLabel).toList());
try {
return requirePdf(printClient.generateInboundMarks(internalToken, requestId, request));
} catch (FeignException | NoFallbackAvailableException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试");
}
}
@@ -22,16 +22,20 @@ import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/** 首次生成后不可变的箱号分配;重打和测量解析只读取该映射。 */
@Service
public class InboundCartonLabelService implements CartonLabelBindingResolver {
private static final int MAX_LABELS = 2000;
private static final int MAX_QR_CODE_POINTS = 34;
private static final Set<String> ALLOCATABLE_STATUSES = Set.of("WAIT_MEASURE", "MEASURING");
private static final Set<String> ALLOCATABLE_STATUSES = Set.of("PREPARING", "WAIT_MEASURE", "MEASURING");
@Resource private InboundCartonLabelRepository repository;
@Resource private OrderCargoClient orderCargoClient;
@Resource private InboundOrderCreationService inboundOrderCreationService;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@Transactional
@@ -40,23 +44,32 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
InboundOrderEntity inbound = repository.lockInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
List<InboundCartonLabelEntity> existing = new ArrayList<>(repository.findByInbound(tenantId, inboundId));
if (!existing.isEmpty()) {
List<InboundCartonLabelEntity> consistent = requireConsistent(existing);
freezeOriginalPackageCount(inbound, consistent.get(0).getTotalCartonCount());
return batch(inbound, consistent);
}
if (!ALLOCATABLE_STATUSES.contains(inbound.getProcessStatus())) {
List<InboundCartonLabelEntity> existing = new ArrayList<>(activeLabels(tenantId, inboundId));
List<InboundCartonLabelEntity> consistent = existing.isEmpty() ? List.of() : requireConsistent(existing);
if (existing.isEmpty() && !ALLOCATABLE_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许首次生成箱号");
}
List<OrderCargoClient.CargoLabelBinding> cargoes = loadCargoes(inbound);
String fingerprint = fingerprint(cargoes);
if (!existing.isEmpty()) {
String existingFingerprint = consistent.get(0).getCargoFingerprint();
if (existingFingerprint == null) {
repository.initializeLegacyFingerprint(tenantId, inboundId, version(consistent.get(0)), fingerprint);
consistent.forEach(label -> label.setCargoFingerprint(fingerprint));
return batch(inbound, consistent);
}
if (existingFingerprint.equals(fingerprint)) return batch(inbound, consistent);
repository.voidActive(tenantId, inboundId, LocalDateTime.now());
existing.clear();
}
int total = cargoes.stream().mapToInt(this::requirePackageCount).sum();
if (total < 1 || total > MAX_LABELS) {
throw new BizException(ResultCode.VALIDATION_FAILED, "唛头箱数必须为1至2000箱");
}
freezeOriginalPackageCount(inbound, total);
requirePrintableInboundCode(inbound.getInboundCode(), total);
repository.updatePackageCount(tenantId, inboundId, total);
inbound.setTotalPackageCount(total);
int labelVersion = repository.nextVersion(tenantId, inboundId);
requirePrintableInboundCode(inbound.getInboundCode(), total, labelVersion);
int cartonIndex = 0;
LocalDateTime now = LocalDateTime.now();
@@ -64,7 +77,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
int detailCount = requirePackageCount(cargo);
for (int offset = 0; offset < detailCount; offset++) {
cartonIndex++;
InboundCartonLabelEntity label = label(inbound, cargo, total, cartonIndex, now);
InboundCartonLabelEntity label = label(inbound, cargo, total, cartonIndex, labelVersion, fingerprint, now);
repository.insert(label);
existing.add(label);
}
@@ -72,6 +85,31 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
return batch(inbound, List.copyOf(existing));
}
public long requireInboundIdByOrder(long tenantId, long orderId) {
requireIds(tenantId, orderId);
InboundOrderEntity inbound = repository.findByOrder(tenantId, orderId);
if (inbound == null) inbound = prepareMissingInbound(tenantId, orderId);
return inbound.getId();
}
private InboundOrderEntity prepareMissingInbound(long tenantId, long orderId) {
try {
ApiResponse<com.baiyue.firstleg.dto.OrderApprovedInboundCommand> response =
orderCargoClient.inboundSnapshot(internalToken, tenantId, orderId);
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.NOT_FOUND, "订单不存在或无法生成预入库单");
}
inboundOrderCreationService.prepareFromDraft(response.data());
InboundOrderEntity prepared = repository.findByOrder(tenantId, orderId);
if (prepared == null) {
throw new BizException(ResultCode.INTERNAL_ERROR, "预入库单补建后未能读取");
}
return prepared;
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法补建预入库单");
}
}
@Transactional
public InboundCartonLabels.Batch list(long tenantId, long inboundId, List<Integer> cartonIndexes) {
InboundCartonLabels.Batch batch = getOrCreate(tenantId, inboundId);
@@ -109,6 +147,9 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
}
InboundCartonLabelEntity label = repository.findByBoxCode(tenantId, boxCode.trim());
if (label == null) throw new BizException(ResultCode.NOT_FOUND, "箱号不存在");
if ("VOID".equals(label.getBatchStatus())) {
throw new BizException(ResultCode.CONFLICT, "箱唛版本已作废,请重新打印");
}
return label;
}
@@ -141,33 +182,20 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
return cargo.packageCount();
}
private void requirePrintableInboundCode(String inboundCode, int total) {
private void requirePrintableInboundCode(String inboundCode, int total, int version) {
if (!StringUtils.hasText(inboundCode) || !inboundCode.matches("[A-Za-z0-9._:-]+")) {
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码不能生成可打印二维码");
}
String longest = inboundCode + "#" + total + "-" + total;
String longest = inboundCode + "#V" + version + "#" + total + "-" + total;
if (longest.codePointCount(0, longest.length()) > MAX_QR_CODE_POINTS) {
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码和箱号组合超过二维码长度限制");
}
}
private void freezeOriginalPackageCount(InboundOrderEntity inbound, int total) {
Integer original = inbound.getTotalPackageCount();
if (original != null && original != total) {
throw new BizException(ResultCode.CONFLICT, "订单总箱数与已冻结箱号数量不一致");
}
if (original == null) {
if (!repository.initializeOriginalPackageCount(inbound.getTenantId(), inbound.getId(), total)) {
throw new BizException(ResultCode.CONFLICT, "订单总箱数已变化,请刷新后重试");
}
inbound.setTotalPackageCount(total);
inbound.setVersion(inbound.getVersion() == null ? 1 : inbound.getVersion() + 1);
}
}
private InboundCartonLabelEntity label(InboundOrderEntity inbound,
OrderCargoClient.CargoLabelBinding cargo,
int total, int index, LocalDateTime now) {
int total, int index, int labelVersion, String fingerprint,
LocalDateTime now) {
InboundCartonLabelEntity label = new InboundCartonLabelEntity();
label.setId(IdWorker.getId());
label.setTenantId(inbound.getTenantId());
@@ -178,9 +206,12 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
label.setCargoName(cargo.cargoName());
label.setDetailPackageCount(cargo.packageCount());
label.setInboundCode(inbound.getInboundCode());
label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index);
label.setBoxCode(inbound.getInboundCode() + "#V" + labelVersion + "#" + total + "-" + index);
label.setTotalCartonCount(total);
label.setCartonIndex(index);
label.setLabelVersion(labelVersion);
label.setBatchStatus("ACTIVE");
label.setCargoFingerprint(fingerprint);
label.setLengthCm(cargo.lengthCm());
label.setWidthCm(cargo.widthCm());
label.setHeightCm(cargo.heightCm());
@@ -203,14 +234,45 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
private List<InboundCartonLabelEntity> requireConsistent(List<InboundCartonLabelEntity> labels) {
int total = labels.get(0).getTotalCartonCount();
int version = version(labels.get(0));
if (total != labels.size()) throw corrupt();
for (int index = 0; index < labels.size(); index++) {
InboundCartonLabelEntity label = labels.get(index);
if (label.getTotalCartonCount() != total || label.getCartonIndex() != index + 1) throw corrupt();
if (label.getTotalCartonCount() != total || label.getCartonIndex() != index + 1
|| version(label) != version || "VOID".equals(label.getBatchStatus())) throw corrupt();
}
return labels;
}
private int version(InboundCartonLabelEntity label) {
return label.getLabelVersion() == null ? 1 : label.getLabelVersion();
}
private List<InboundCartonLabelEntity> activeLabels(long tenantId, long inboundId) {
List<InboundCartonLabelEntity> result = repository.findActiveByInbound(tenantId, inboundId);
if (result != null && !result.isEmpty()) return result;
return repository.findByInbound(tenantId, inboundId).stream()
.filter(label -> label.getBatchStatus() == null || "ACTIVE".equals(label.getBatchStatus()))
.toList();
}
private String fingerprint(List<OrderCargoClient.CargoLabelBinding> cargoes) {
String source = cargoes.stream().map(cargo -> String.join("|", String.valueOf(cargo.itemNo()),
cargo.cargoName() == null ? "" : cargo.cargoName().trim(), String.valueOf(cargo.packageCount()),
decimalOrEmpty(cargo.lengthCm()), decimalOrEmpty(cargo.widthCm()), decimalOrEmpty(cargo.heightCm())))
.collect(java.util.stream.Collectors.joining("\n"));
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(source.getBytes(StandardCharsets.UTF_8));
StringBuilder value = new StringBuilder(64);
for (byte item : digest) value.append(String.format("%02x", item));
return value.toString();
} catch (NoSuchAlgorithmException exception) {
throw new BizException(ResultCode.INTERNAL_ERROR, "无法生成箱唛货物指纹");
}
}
private String decimalOrEmpty(BigDecimal value) { return value == null ? "" : decimal(value); }
private BizException corrupt() {
return new BizException(ResultCode.INTERNAL_ERROR, "入库单箱号映射不完整,请联系管理员");
}
@@ -36,6 +36,7 @@ public class InboundOrderCreationService {
long inboundId = requireMatchingInboundCode(existing, command.inboundCode());
FulfillmentTaskEntity task = fulfillmentTasks.provision(command, LocalDateTime.now());
linkTask(existing, task.getId());
upgradePreparing(existing, command);
return inboundId;
}
@@ -107,6 +108,146 @@ public class InboundOrderCreationService {
return inbound.getId();
}
/**
* Creates the real first-leg aggregate used by draft carton labels. It is
* deliberately not a fulfillment task yet; approval upgrades the same row
* in-place so the inbound id printed on labels never changes.
*/
@Transactional
public long prepareFromDraft(OrderApprovedInboundCommand command) {
InboundOrderEntity existing = findByOrder(command.tenantId(), command.orderId(), false);
if (existing != null) {
long inboundId = requireMatchingInboundCode(existing, command.inboundCode());
refreshPreparing(existing, command);
return inboundId;
}
LocalDateTime now = LocalDateTime.now();
InboundOrderEntity inbound = newInbound(command, now, null, "PREPARING", "DRAFT");
try {
orders.insert(inbound);
} catch (DuplicateKeyException duplicate) {
InboundOrderEntity winner = findByOrder(command.tenantId(), command.orderId(), true);
if (winner == null) {
throw new BizException(ResultCode.CONFLICT, "入仓码已被其他订单使用");
}
return requireMatchingInboundCode(winner, command.inboundCode());
}
addHistory(inbound.getId(), command.tenantId(), null, "PREPARING", "ORDER_DRAFT_PREPARED", now);
return inbound.getId();
}
private void refreshPreparing(InboundOrderEntity existing, OrderApprovedInboundCommand command) {
if (!"PREPARING".equals(existing.getProcessStatus())) return;
orders.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, existing.getTenantId())
.eq(InboundOrderEntity::getId, existing.getId())
.eq(InboundOrderEntity::getProcessStatus, "PREPARING")
.set(InboundOrderEntity::getOrderNo, command.orderNo())
.set(InboundOrderEntity::getCustomerId, command.customerId())
.set(InboundOrderEntity::getCustomerName, command.customerName())
.set(InboundOrderEntity::getWarehouseId, command.warehouseId())
.set(InboundOrderEntity::getWarehouseName, command.warehouseName())
.set(InboundOrderEntity::getDeliveryWarehouseId, command.deliveryWarehouseId())
.set(InboundOrderEntity::getDeliveryWarehouseName, command.deliveryWarehouseName())
.set(InboundOrderEntity::getExpectedArrivalAt, command.expectedArrivalAt())
.set(InboundOrderEntity::getBusinessType, command.businessType())
.set(InboundOrderEntity::getChannelCode, command.channelCode())
.set(InboundOrderEntity::getChannelName, command.channelName())
.set(InboundOrderEntity::getDestinationType, command.destinationType())
.set(InboundOrderEntity::getDestinationWarehouseId, command.destinationWarehouseId())
.set(InboundOrderEntity::getDestinationCode, command.destinationCode())
.set(InboundOrderEntity::getDestinationName, command.destinationName())
.set(InboundOrderEntity::getPublicRemark, command.publicRemark())
.set(InboundOrderEntity::getTotalPackageCount, command.totalPackageCount())
.set(InboundOrderEntity::getUpdatedAt, LocalDateTime.now())
.setSql("version = version + 1"));
}
private void upgradePreparing(InboundOrderEntity existing, OrderApprovedInboundCommand command) {
if (!"PREPARING".equals(existing.getProcessStatus())) return;
var update = new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, existing.getTenantId())
.eq(InboundOrderEntity::getId, existing.getId())
.eq(InboundOrderEntity::getProcessStatus, "PREPARING")
.set(InboundOrderEntity::getProcessStatus, "WAIT_MEASURE")
.set(InboundOrderEntity::getOrderStatusSnapshot, "APPROVED")
.set(InboundOrderEntity::getTotalPackageCount, command.totalPackageCount())
.set(InboundOrderEntity::getUpdatedAt, LocalDateTime.now())
.setSql("version = version + 1");
if (orders.update(null, update) != 1) {
InboundOrderEntity current = findByOrder(existing.getTenantId(), existing.getOrderId(), true);
if (current == null || !"WAIT_MEASURE".equals(current.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "预入库单审核升级发生并发冲突");
}
return;
}
addHistory(existing.getId(), existing.getTenantId(), "PREPARING", "WAIT_MEASURE",
"ORDER_APPROVED", LocalDateTime.now());
}
private InboundOrderEntity newInbound(OrderApprovedInboundCommand command, LocalDateTime now,
Long taskId, String processStatus, String orderStatus) {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(IdWorker.getId());
inbound.setTenantId(command.tenantId());
inbound.setTaskId(taskId);
inbound.setOrderId(command.orderId());
inbound.setOrderNo(command.orderNo());
inbound.setInboundCode(command.inboundCode());
inbound.setProcessStatus(processStatus);
inbound.setOrderStatusSnapshot(orderStatus);
inbound.setMeasureReviewStatus("NOT_SUBMITTED");
inbound.setFeeStatus("UNPRICED");
inbound.setFeeReviewStatus("NOT_SUBMITTED");
inbound.setNotificationStatus("NOT_CREATED");
inbound.setExpectedArrivalAt(command.expectedArrivalAt());
inbound.setCustomerId(command.customerId());
inbound.setCustomerName(command.customerName());
inbound.setWarehouseId(command.warehouseId());
inbound.setWarehouseName(command.warehouseName());
inbound.setDeliveryWarehouseId(command.deliveryWarehouseId());
inbound.setDeliveryWarehouseName(command.deliveryWarehouseName());
inbound.setBusinessType(command.businessType());
inbound.setChannelCode(command.channelCode());
inbound.setChannelName(command.channelName());
inbound.setCarrierId(command.carrierId());
inbound.setCarrierName(command.carrierName());
inbound.setTaxMode(command.taxMode());
inbound.setPricingTaxMode(command.pricingTaxMode());
inbound.setContainerMode(command.containerMode());
inbound.setCustomsInspection(command.customsInspection());
inbound.setDestinationType(command.destinationType());
inbound.setDestinationWarehouseId(command.destinationWarehouseId());
inbound.setDestinationCode(command.destinationCode());
inbound.setDestinationName(command.destinationName());
inbound.setExpressCompanyName(command.expressCompanyName());
inbound.setExpressTrackingNo(command.expressTrackingNo());
inbound.setPublicRemark(command.publicRemark());
inbound.setCreatedBy(0L);
inbound.setUpdatedBy(0L);
inbound.setCreatedAt(now);
inbound.setUpdatedAt(now);
inbound.setDeleted(false);
inbound.setVersion(0);
return inbound;
}
private void addHistory(long inboundId, long tenantId, String from, String to,
String action, LocalDateTime now) {
InboundStatusHistoryEntity history = new InboundStatusHistoryEntity();
history.setId(IdWorker.getId());
history.setTenantId(tenantId);
history.setInboundId(inboundId);
history.setFromStatus(from);
history.setToStatus(to);
history.setAction(action);
history.setOperatorId(0L);
history.setOperatorName("订单服务");
history.setOccurredAt(now);
histories.insert(history);
}
private InboundOrderEntity findByOrder(long tenantId, long orderId, boolean currentRead) {
var query = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
@@ -0,0 +1,47 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventEnvelopeValidator;
import com.baiyue.firstleg.client.EventBridgeClient;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.repository.LoadingCompletedOutboxRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.UUID;
/** 至少一次投递装柜完成跨区事件;事件桥按 eventId 幂等接收。 */
@Component @Slf4j
public class LoadingCompletedOutboxRelay {
@Resource private LoadingCompletedOutboxRepository repository;
@Resource private EventBridgeClient client;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
private final String workerId = UUID.randomUUID().toString();
@Scheduled(fixedDelayString = "${baiyue.lastleg.outbox-relay.fixed-delay-ms:5000}")
public void relay() {
LocalDateTime now=LocalDateTime.now();
for (InboundOutboxEntity candidate:repository.pending(50,now)) {
if(!repository.claim(candidate.getId(),workerId,now)) continue;
InboundOutboxEntity row=repository.claimed(candidate.getId(),workerId);
if(row==null) continue;
try {
EventEnvelope event=objectMapper.readValue(row.getPayloadJson(),EventEnvelope.class);
EventEnvelopeValidator.requireValid(event);
var response=client.receive(internalToken,event);
if(response==null || !response.successful() || response.data()==null
|| !event.eventId().equals(response.data().eventId())) throw new IllegalStateException("事件桥未确认装柜完成事件");
repository.published(row.getId(),workerId,LocalDateTime.now());
} catch(Exception exception) {
repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(30),exception.getMessage());
log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),exception.getMessage());
}
}
}
}
@@ -0,0 +1,103 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventIdentifiers;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.FirstLegLoadingPlanCompletedV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** 在装柜完成事务内按客户写跨区业务 Outbox。 */
@Service
public class LoadingCompletedOutboxService {
private static final long HANDOVER_VERSION = 1L;
@Resource private LoadingCompletedEventMapper snapshotMapper;
@Resource private InboundOutboxMapper outboxMapper;
@Resource private ObjectMapper objectMapper;
public void publish(long planId) {
List<Row> rows = snapshotMapper.rows(planId);
if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件");
rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId,
java.util.TreeMap::new, java.util.stream.Collectors.toList()))
.forEach((accountId, accountRows) -> insert(envelope(accountId, accountRows)));
}
private EventEnvelope envelope(long accountId, List<Row> rows) {
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<FirstLegLoadingPlanCompletedV1.Order> orders = byInbound.values().stream()
.map(this::order).toList();
var payload = new FirstLegLoadingPlanCompletedV1(String.valueOf(first.getLoadingPlanId()),
first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(),
string(first.getWarehouseId()), first.getWarehouseName(), orders);
JsonNode payloadNode = objectMapper.valueToTree(payload);
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + first.getLoadingPlanId() + ':' + accountId + ':' + HANDOVER_VERSION;
String traceId = EventIdentifiers.newUuid();
return new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 1,
Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(accountId), "LOADING_PLAN",
String.valueOf(first.getLoadingPlanId()), first.getAggregateVersion(), HANDOVER_VERSION,
traceId, eventKey, null, payloadNode, hash).requireValid();
}
private FirstLegLoadingPlanCompletedV1.Order order(List<Row> values) {
List<Row> sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList();
Row first = sorted.get(0);
List<FirstLegLoadingPlanCompletedV1.Item> items = sorted.stream().map(row ->
new FirstLegLoadingPlanCompletedV1.Item(string(row.getSourceInboundDetailId()), row.getSkuCode(),
row.getProductNameZh(), row.getProductNameEn(), row.getPackageCount(), row.getProductQuantity(),
row.getPalletCount(), row.getGrossWeightKg(), row.getNetWeightKg(), row.getDimensionalWeightKg(),
row.getVolumeCbm(), string(row.getDestinationWarehouseId()), row.getDestinationWarehouseCode(),
row.getDestinationWarehouseName(), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark()))
.toList();
return new FirstLegLoadingPlanCompletedV1.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), sumNullableInt(sorted, Row::getPalletCount),
sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg), sum(sorted, Row::getVolumeCbm), items);
}
private void insert(EventEnvelope envelope) {
InboundOutboxEntity value = new InboundOutboxEntity();
value.setId(IdWorker.getId()); value.setTenantId(Long.valueOf(envelope.tenantId()));
value.setAggregateType(envelope.aggregateType()); value.setAggregateId(Long.valueOf(envelope.aggregateId()));
value.setEventType(envelope.eventType()); value.setEventKey(envelope.correlationId());
value.setEventVersion(envelope.eventVersion()); value.setAggregateSequence((int) envelope.aggregateVersion());
value.setPayloadHash(envelope.payloadHash()); value.setPayloadJson(json(envelope));
value.setPublishStatus("PENDING"); value.setRetryCount(0); value.setCreatedAt(LocalDateTime.now());
try { outboxMapper.insert(value); } catch (DuplicateKeyException ignored) { }
}
private String json(Object value) { try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException e) { throw new BizException(ResultCode.INTERNAL_ERROR, "装柜完成事件序列化失败"); } }
private static String string(Long value) { return value == null ? null : String.valueOf(value); }
private static int sumInt(List<Row> rows, java.util.function.Function<Row,Integer> getter) { return rows.stream().map(getter).filter(java.util.Objects::nonNull).mapToInt(Integer::intValue).sum(); }
private static Integer sumNullableInt(List<Row> rows, java.util.function.Function<Row,Integer> getter) { List<Integer> values=rows.stream().map(getter).filter(java.util.Objects::nonNull).toList(); return values.isEmpty()?null:values.stream().mapToInt(Integer::intValue).sum(); }
private static BigDecimal sum(List<Row> rows, java.util.function.Function<Row,BigDecimal> getter) { List<BigDecimal> values=rows.stream().map(getter).filter(java.util.Objects::nonNull).toList(); return values.isEmpty()?null:values.stream().reduce(BigDecimal.ZERO, BigDecimal::add); }
}
@@ -57,6 +57,8 @@ public class LoadingListService {
@Resource
private LoadingPickRepository pickRepository;
@Resource
private LoadingCompletedOutboxService loadingCompletedOutboxService;
@Resource
private ObjectMapper objectMapper;
@Resource
private LoadingDocumentFileClient documentFiles;
@@ -455,6 +457,7 @@ public class LoadingListService {
json(values("source", "PDA", "pickTaskId", pickTaskId, "status", "COMPLETED",
"version", before.getVersion() + 1)), operatorId, trim(operatorName),
"PDA已人工确认装柜完成");
loadingCompletedOutboxService.publish(planId);
}
private AggregateRow requirePlan(long planId) {