Merge test fixes into dev dropship flow

This commit is contained in:
2026-09-10 18:18:41 +08:00
131 changed files with 3062 additions and 651 deletions
@@ -41,7 +41,7 @@ public class InternalCustomerDimensionConfigController {
@Valid @RequestBody InitializeRequest request) {
requireToken(token);
return ApiResponse.success(service.initializeDefault(
request.tenantId(), request.operatorId(), request.initializationKey()));
request.tenantId(), request.operatorId(), request.initializationKey(), request.customerName()));
}
@DeleteMapping("/defaults")
@@ -92,7 +92,6 @@ public class PlatformContainerController {
@PathVariable @Positive long id,
@PathVariable @Positive long fileId) {
FirstlegScopeGuard.platform(user);
service.requireDomestic(id);
var value = overview.downloadPhoto(id, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.inline()
@@ -93,6 +93,40 @@ public class PlatformInboundMeasurementController {
return ApiResponse.success(service.saveDraft(tenantId, inboundId, user.userId(), request));
}
@BusinessAudit(event = "INBOUND_MEASUREMENT_CALCULATION_STRATEGY_UPDATE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "更新入库测量箱规计算规则")
@Operation(summary = "更新箱规计算规则",
description = "独立更新当前测量批次全部明细的箱规计算规则并重算体积;不改变测量提交或审核状态。"
+ "仅当客户已确认费用且该票已装柜完成时冻结。")
@PutMapping("/measurement/calculation-strategy")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')")
public ApiResponse<InboundMeasurementResponses.CalculationStrategyUpdated> updateCalculationStrategy(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@Valid @RequestBody InboundMeasurementRequests.UpdateCalculationStrategyRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.updateCalculationStrategy(tenantId, inboundId, request));
}
@BusinessAudit(event = "INBOUND_MEASUREMENT_OUTER_DIMENSIONS_UPDATE",
resourceType = "INBOUND_MEASUREMENT_ITEM", resourceId = "#itemId",
description = "更新仓库外箱长宽高")
@Operation(summary = "更新仓库外箱长宽高",
description = "独立更新当前测量批次指定明细的仓库外箱尺寸并重算体积;不改变测量提交或审核状态。"
+ "仅当客户已确认费用且该票已装柜完成时冻结。")
@PutMapping("/measurement/items/{itemId}/outer-dimensions")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')")
public ApiResponse<InboundMeasurementResponses.OuterDimensionsUpdated> updateOuterDimensions(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@PathVariable long itemId,
@Valid @RequestBody InboundMeasurementRequests.UpdateOuterDimensionsRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.updateOuterDimensions(tenantId, inboundId, itemId, request));
}
@BusinessAudit(event = "INBOUND_MEASUREMENT_SUBMIT", resourceType = "INBOUND",
resourceId = "#inboundId", description = "确认测量并提交审核")
@Operation(summary = "确认测量并提交审核",
@@ -10,6 +10,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundOrderDetail;
import com.baiyue.firstleg.dto.InboundOrderQuery;
import com.baiyue.firstleg.dto.InboundOrderSummary;
import com.baiyue.firstleg.dto.InboundCartonLabels;
import com.baiyue.firstleg.dto.InboundHoldDtos.CommandRequest;
import com.baiyue.firstleg.dto.InboundHoldDtos.Detail;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
@@ -22,6 +23,7 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
@@ -29,6 +31,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RequestParam;
@@ -160,6 +163,18 @@ public class PlatformInboundOrderController {
return cartonLabelPrintService.generate(tenantId, inboundId, cartonIndex, requestId);
}
@Operation(summary = "批量生成入仓箱唛 PDF",
description = "按所选客户和入库单聚合生成一个 PDF;首次调用冻结各入库单整单箱号。")
@PostMapping("/carton-label-pdf")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:view')")
public ResponseEntity<byte[]> printCartonLabelsBatch(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestBody @Size(min = 1, max = 200) List<InboundCartonLabels.PlatformBatchItem> items,
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
requirePlatform(user);
return cartonLabelPrintService.generateForPlatformInbounds(items, requestId);
}
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
FirstlegScopeGuard.platform(user, tenantId);
}
@@ -22,7 +22,8 @@ public final class ContainerCustomsDtos {
boolean frozen, FileView completion) {}
public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId,
String planNo, String cabinetNo, String containerNo, String status, String merchantStatus,
String planNo, String cabinetNo, String containerNo, String status, boolean hasActualCargo,
String merchantStatus,
int version, int taxRefundCount, int coveredTaxRefundCount,
List<InboundView> inbounds, List<DocumentView> documents,
LocalDateTime startedAt, LocalDateTime completedAt) {}
@@ -34,6 +34,15 @@ public interface ContainerCustomsMapper {
""")
List<InboundRow> planInbounds(@Param("planId") long planId);
@Select("""
SELECT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId,
p.plan_status loadingPlanStatus,b.cabinet_no cabinetNo,b.container_no containerNo
FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
WHERE p.id=#{planId}
""")
PlanRow plan(@Param("planId") long planId);
@Select("""
SELECT p.id FROM fl_loading_plan p
JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0
@@ -331,6 +340,8 @@ public interface ContainerCustomsMapper {
record InboundRow(Long loadingPlanId,String planNo,Long containerBookingId,String loadingPlanStatus,
String cabinetNo,String containerNo,Long inboundId,Long orderId,String orderNo,String inboundCode,
Long tenantId,String customerName,String taxMode) {}
record PlanRow(Long loadingPlanId,String planNo,Long containerBookingId,String loadingPlanStatus,
String cabinetNo,String containerNo) {}
record ContainerRow(Long id,Long loadingPlanId,Long containerBookingId,String status,Long startedBy,
String startedByName,LocalDateTime startedAt,Long completedBy,String completedByName,
LocalDateTime completedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) {}
@@ -44,6 +44,9 @@ public class ContainerCustomsService {
@Transactional
public ContainerView platformView(long planId) {
List<InboundRow> rows = actualPlanRows(planId);
PlanRow plan = rows.isEmpty() ? requiredPlan(planId) : plan(rows.get(0));
if (!"COMPLETED".equals(plan.loadingPlanStatus())) return null;
ContainerRow container = ensure(planId);
return view(container, null, false);
}
@@ -155,6 +158,7 @@ public class ContainerCustomsService {
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return platformView(planId);
ensure(planId);
if (actualPlanRows(planId).isEmpty()) throw conflict("当前装柜计划没有实际货物,无需报关");
ContainerRow locked = mapper.lockContainer(planId);
if (locked == null || !READY.equals(locked.status())) throw conflict("当前柜报关资料尚未齐全或已开始报关");
if (!Objects.equals(locked.version(), request.expectedVersion())) throw concurrent();
@@ -219,7 +223,8 @@ public class ContainerCustomsService {
ContainerRow container = mapper.lockContainer(planId);
if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中");
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
List<InboundRow> actual=planRows(planId);
List<InboundRow> actual=actualPlanRows(planId);
if(actual.isEmpty())throw conflict("当前装柜计划没有实际货物,无需报关");
if(actual.stream().anyMatch(row->!"BUY_DECLARATION".equals(row.taxMode())))throw conflict("全买单柜完成只允许全部实际行均为BUY_DECLARATION");
completeContainer(container, operatorId, operatorName, request.remark());
completeCommand(operatorId, action, key, container.id());
@@ -245,11 +250,11 @@ public class ContainerCustomsService {
}
private ContainerRow ensure(long planId) {
List<InboundRow> rows = planRows(planId);
InboundRow first = rows.get(0);
if (!"COMPLETED".equals(first.loadingPlanStatus())) throw conflict("装柜完成后才能处理报关资料");
List<InboundRow> rows = actualPlanRows(planId);
PlanRow plan = rows.isEmpty() ? requiredPlan(planId) : plan(rows.get(0));
if (!"COMPLETED".equals(plan.loadingPlanStatus())) throw conflict("装柜完成后才能处理报关资料");
boolean refund = rows.stream().anyMatch(row -> "TAX_REFUND".equals(row.taxMode()));
mapper.insertContainer(IdWorker.getId(), planId, first.containerBookingId(), refund ? WAITING : READY);
mapper.insertContainer(IdWorker.getId(), planId, plan.containerBookingId(), refund ? WAITING : READY);
return refresh(planId);
}
@@ -283,11 +288,27 @@ public class ContainerCustomsService {
}
private List<InboundRow> planRows(long planId) {
List<InboundRow> rows = mapper.planInbounds(planId);
List<InboundRow> rows = actualPlanRows(planId);
if (rows == null || rows.isEmpty()) throw notFound("未找到当前装柜计划的实际货物明细");
return rows;
}
private List<InboundRow> actualPlanRows(long planId) {
List<InboundRow> rows = mapper.planInbounds(planId);
return rows == null ? List.of() : rows;
}
private PlanRow requiredPlan(long planId) {
PlanRow plan = mapper.plan(planId);
if (plan == null) throw notFound("装柜计划不存在或不可用");
return plan;
}
private PlanRow plan(InboundRow row) {
return new PlanRow(row.loadingPlanId(), row.planNo(), row.containerBookingId(), row.loadingPlanStatus(),
row.cabinetNo(), row.containerNo());
}
private List<InboundRow> validateScope(long planId, long tenantId, List<Long> inboundIds) {
if (inboundIds == null || inboundIds.isEmpty() || inboundIds.size() > 200) throw bad("至少选择一个且最多200个入仓码");
Set<Long> requested = new LinkedHashSet<>(inboundIds);
@@ -304,7 +325,8 @@ public class ContainerCustomsService {
}
private ContainerView view(ContainerRow container, Long tenantId, boolean merchant) {
List<InboundRow> rows = planRows(container.loadingPlanId());
List<InboundRow> rows = actualPlanRows(container.loadingPlanId());
PlanRow plan = rows.isEmpty() ? requiredPlan(container.loadingPlanId()) : plan(rows.get(0));
List<DocumentRow> documents = mapper.documents(container.id(), tenantId);
Set<Long> covered = documents.stream().flatMap(doc -> mapper.scopes(doc.id()).stream()).map(ScopeRow::inboundId).collect(Collectors.toSet());
List<InboundRow> visibleRows=rows.stream().filter(row -> tenantId == null || (row.tenantId().equals(tenantId)&&"TAX_REFUND".equals(row.taxMode()))).toList();
@@ -315,9 +337,8 @@ public class ContainerCustomsService {
int refund = (int) visibleRows.stream().filter(row -> "TAX_REFUND".equals(row.taxMode())).count();
int coveredRefund = (int) visibleRows.stream().filter(row -> covered.contains(row.inboundId())).count();
String merchantStatus=tenantId==null?null:merchantStatus(container.status(),refund,coveredRefund);
InboundRow first = rows.get(0);
return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), first.planNo(),
first.cabinetNo(), first.containerNo(), container.status(), merchantStatus, container.version(), refund, coveredRefund,
return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), plan.planNo(),
plan.cabinetNo(), plan.containerNo(), container.status(), !rows.isEmpty(), merchantStatus, container.version(), refund, coveredRefund,
inbounds, documentViews, container.startedAt(), container.completedAt());
}
@@ -60,8 +60,10 @@ public final class CustomerDimensionConfigDtos {
public record InitializeRequest(
@NotNull @Min(1) Long tenantId,
@NotNull @Min(0) Long operatorId,
@NotBlank @Size(max = 128) String initializationKey
@NotBlank @Size(max = 128) String initializationKey,
@NotBlank @Size(max = 128) String customerName
) { }
public record InitializeResponse(Long configId, boolean createdByThisInitialization) { }
public record InitializeResponse(Long configId, Long chargeRuleId,
boolean createdByThisInitialization) { }
}
@@ -3,6 +3,8 @@ package com.baiyue.firstleg.dto;
import java.math.BigDecimal;
import java.util.List;
import jakarta.validation.constraints.Min;
/** 稳定箱号分配、补打和测量解析契约。 */
public final class InboundCartonLabels {
private InboundCartonLabels() {
@@ -12,6 +14,9 @@ public final class InboundCartonLabels {
List<Label> labels) {
}
public record PlatformBatchItem(@Min(1) long tenantId, @Min(1) long inboundId) {
}
public record Label(Long id, Long inboundDetailId, Integer cargoItemNo, String cargoName,
Integer detailPackageCount, String boxCode, Integer totalCount,
Integer cartonIndex, String boxSpecification, BigDecimal lengthCm,
@@ -87,6 +87,26 @@ public final class InboundMeasurementRequests {
) {
}
public record UpdateCalculationStrategyRequest(
@NotNull @Positive Long measurementId,
@NotNull @PositiveOrZero Integer version,
@NotBlank @jakarta.validation.constraints.Pattern(regexp = "ACTUAL|ADD|MULTIPLY") String coefficientMode,
@Positive Long chargeRuleId
) {
}
public record UpdateOuterDimensionsRequest(
@NotNull @Positive Long measurementId,
@NotNull @PositiveOrZero Integer version,
@DecimalMin("0.001") @DecimalMax("999999999.999")
@Digits(integer = 9, fraction = 3) BigDecimal outerLengthCm,
@DecimalMin("0.001") @DecimalMax("999999999.999")
@Digits(integer = 9, fraction = 3) BigDecimal outerWidthCm,
@DecimalMin("0.001") @DecimalMax("999999999.999")
@Digits(integer = 9, fraction = 3) BigDecimal outerHeightCm
) {
}
public record ConfirmedPackageCountRequest(
@NotNull @PositiveOrZero Integer measurementVersion,
@NotNull @Positive @Max(1000000) Integer confirmedPackageCount
@@ -183,6 +183,16 @@ public final class InboundMeasurementResponses {
public record DraftSaved(Long measurementId, Integer version, String status) {
}
public record CalculationStrategyUpdated(
Long measurementId, Integer version, String status, String coefficientMode, Long chargeRuleId
) {
}
public record OuterDimensionsUpdated(
Long measurementId, Integer version, String status, Long itemId
) {
}
public record MeasurementSubmitted(Long measurementId, Integer version, String processStatus,
String measureReviewStatus,
@Schema(description = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘",
@@ -38,4 +38,6 @@ public class CustomerChargeRuleEntity extends BaseEntity {
private LocalDateTime effectiveTo;
private String status;
private String remark;
/** 客户创建执行器的幂等键;人工创建的规则为空。 */
private String initializationKey;
}
@@ -15,7 +15,6 @@ import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.CompletionTarget;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.loading.repository.LoadingRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.service.FbaPricingService;
import com.baiyue.firstleg.service.InboundHoldGuard;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -42,7 +41,6 @@ public class LoadingDeliveryService {
@Resource private LoadingCompletionGateService completionGate;
@Resource private InboundStatusHistoryMapper histories;
@Resource private CabinetLoadingEvidenceService cabinetEvidenceService;
@Resource private FbaPricingService pricingService;
@Resource private ObjectMapper objectMapper;
@Resource private InboundHoldGuard inboundHoldGuard;
@@ -69,7 +67,6 @@ public class LoadingDeliveryService {
}
if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) {
requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion);
pricingService.autoConfirmAfterLoading(tenantId, inboundId);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse(inbound.processStatus()));
}
@@ -80,7 +77,6 @@ public class LoadingDeliveryService {
requireCabinetEvidence(tenantId, inboundId);
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName);
pricingService.autoConfirmAfterLoading(tenantId, inboundId);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED"));
}
@@ -124,7 +120,6 @@ public class LoadingDeliveryService {
CompletionTarget target,
boolean acceptedPdaCompletion) {
if ("LOADED".equals(target.loadedChildStatus())) {
pricingService.autoConfirmAfterLoading(tenantId, target.completionInboundId());
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED"));
}
@@ -140,7 +135,6 @@ public class LoadingDeliveryService {
repository.aggregateRootLoaded(tenantId, target.rootInboundId(), operatorId);
addHistory(tenantId, target.completionInboundId(), "LOADING", "LOADED", COMPLETE_LOADING,
request.remark(), operatorId, operatorName);
pricingService.autoConfirmAfterLoading(tenantId, target.completionInboundId());
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED"));
}
@@ -1,7 +1,50 @@
package com.baiyue.firstleg.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
@Mapper
public interface CustomerChargeRuleMapper extends BaseMapper<CustomerChargeRuleEntity> {}
public interface CustomerChargeRuleMapper extends BaseMapper<CustomerChargeRuleEntity> {
@Select("""
SELECT id, tenant_id, rule_code, rule_version, customer_id, customer_name, business_type,
charge_mode, tax_mode, conversion_factor, length_factor, width_factor, height_factor,
default_rule, rounding_scale, rounding_mode, currency, effective_from, effective_to,
status, remark, initialization_key, created_by, created_at, updated_by, updated_at,
deleted, version
FROM fl_customer_charge_rule
WHERE tenant_id = #{tenantId} AND initialization_key = #{initializationKey} AND deleted = 0
LIMIT 1
""")
CustomerChargeRuleEntity selectByInitializationKey(
@Param("tenantId") long tenantId, @Param("initializationKey") String initializationKey);
@Select("""
SELECT id, tenant_id, rule_code, rule_version, customer_id, customer_name, business_type,
charge_mode, tax_mode, conversion_factor, length_factor, width_factor, height_factor,
default_rule, rounding_scale, rounding_mode, currency, effective_from, effective_to,
status, remark, initialization_key, created_by, created_at, updated_by, updated_at,
deleted, version
FROM fl_customer_charge_rule
WHERE tenant_id = #{tenantId} AND initialization_key = #{initializationKey} AND deleted = 0
LIMIT 1 FOR UPDATE
""")
CustomerChargeRuleEntity selectByInitializationKeyForUpdate(
@Param("tenantId") long tenantId, @Param("initializationKey") String initializationKey);
@Delete("""
DELETE FROM fl_customer_charge_rule
WHERE tenant_id = #{tenantId} AND initialization_key = #{initializationKey}
AND rule_code = 'DEFAULT-DIMENSION-WEIGHT' AND rule_version = 1
AND customer_id = #{tenantId} AND business_type = 'FBA' AND charge_mode = 'CBM'
AND tax_mode = 'TAX_INCLUDED' AND conversion_factor = 167.000000
AND length_factor = 1.100000 AND width_factor = 1.100000 AND height_factor = 1.100000
AND default_rule = 1 AND rounding_scale = 2 AND rounding_mode = 'HALF_UP'
AND currency = 'CNY' AND effective_to IS NULL AND status = 'ACTIVE'
AND deleted = 0 AND version = 0
""")
int deleteUnchangedInitializedRule(
@Param("tenantId") long tenantId, @Param("initializationKey") String initializationKey);
}
@@ -150,15 +150,13 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
AND plan.id = pick_task.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
JOIN fl_inbound_order inbound_order ON inbound_order.id = plan_item.inbound_id
AND inbound_order.tenant_id = plan_item.account_id AND inbound_order.deleted = 0
WHERE pick_item.pick_task_id = #{taskId} AND booking.warehouse_id = #{warehouseId}
AND plan_item.deleted = 0
WHERE pick_item.pick_task_id = #{taskId} AND plan_item.deleted = 0
ORDER BY pick_item.id ASC
""")
java.util.List<LoadingPickItemEntity> selectTaskItemsWithAccount(@Param("taskId") long taskId,
@Param("warehouseId") long warehouseId);
@Param("warehouseId") long warehouseId);
@Select("""
SELECT pick_item.*, plan_item.unit_type, plan_item.pallet_id, plan_item.pallet_no, plan_item.indivisible
@@ -167,11 +165,10 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
AND plan.id = pick_task.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
JOIN fl_inbound_order inbound_order ON inbound_order.id = plan_item.inbound_id
AND inbound_order.tenant_id = plan_item.account_id AND inbound_order.deleted = 0
WHERE pick_item.pick_task_id = #{taskId} AND pick_item.id = #{itemId}
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0
AND plan_item.deleted = 0
""")
LoadingPickItemEntity selectTaskItemWithAccount(@Param("taskId") long taskId,
@Param("itemId") long itemId,
@@ -185,10 +182,8 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
version = version + 1
WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
AND plan_item.deleted = 0)
AND picked_package_count + #{count} <= planned_package_count
""")
int pick(@Param("id") long id, @Param("count") int count,
@@ -201,10 +196,8 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
version = version + 1
WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
AND plan_item.deleted = 0)
AND item_status IN ('PENDING', 'PICKING')
AND picked_package_count < #{finalCount}
AND #{finalCount} < planned_package_count
@@ -218,13 +211,12 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
SET item_status = 'SKIPPED', version = version + 1
WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
AND plan_item.deleted = 0)
AND item_status = 'PENDING' AND picked_package_count = 0
""")
int skip(@Param("id") long id, @Param("version") int version, @Param("warehouseId") long warehouseId);
int skip(@Param("id") long id, @Param("version") int version,
@Param("warehouseId") long warehouseId);
@Select("""
SELECT COUNT(*)
@@ -50,7 +50,6 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE booking.container_no = #{containerNo} AND booking.record_status = 'ACTIVE'
AND task.task_status = 'LOADING'
AND booking.warehouse_id = #{warehouseId}
LIMIT 1
""")
LoadingPickTaskEntity selectLoadingByContainerAndAccount(@Param("containerNo") String containerNo,
@@ -59,13 +58,13 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
@Select("SELECT task.* FROM fl_loading_pick_task task JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id "
+ "JOIN fl_container_booking booking ON booking.id=plan.container_booking_id "
+ "WHERE task.task_status='LOADING' AND booking.record_status='ACTIVE' "
+ "AND booking.cabinet_no=#{cabinetNo} AND booking.warehouse_id=#{warehouseId} LIMIT 1")
+ "AND booking.cabinet_no=#{cabinetNo} LIMIT 1")
LoadingPickTaskEntity selectLoadingByCabinet(@Param("cabinetNo") String cabinetNo, @Param("warehouseId") long warehouseId);
@Select("SELECT task.* FROM fl_loading_pick_task task JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id "
+ "JOIN fl_container_booking booking ON booking.id=plan.container_booking_id "
+ "WHERE task.task_status='LOADING' AND booking.record_status='ACTIVE' "
+ "AND task.loading_plan_id=#{loadingPlanId} AND booking.warehouse_id=#{warehouseId} LIMIT 1")
+ "AND task.loading_plan_id=#{loadingPlanId} LIMIT 1")
LoadingPickTaskEntity selectLoadingByPlan(@Param("loadingPlanId") long loadingPlanId, @Param("warehouseId") long warehouseId);
@Select("SELECT booking.* FROM fl_loading_pick_task task "
@@ -97,7 +97,7 @@ public class OutboundDispatchService {
OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId);
if (carrierSchedules != null) {
if (ocean == null || ocean.id() == null || ocean.customerId() == null) {
if (ocean == null || ocean.id() == null) {
throw conflict("当前货柜未关联有效订舱,无法创建船司查询任务");
}
carrierSchedules.ensureFromOutboundConfirm(ocean.customerId(), ocean.id(), containerBookingId,
@@ -129,7 +129,7 @@ public class OutboundDispatchService {
if (!Objects.equals(visible.version(), request.expectedVersion())) throw concurrent();
OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId);
if (carrierSchedules != null) {
if (ocean == null || ocean.id() == null || ocean.customerId() == null) {
if (ocean == null || ocean.id() == null) {
throw conflict("无法核验货柜后续船节点,禁止撤销国内出仓");
}
if (carrierSchedules.hasOutboundDepartureFact(ocean.customerId(), ocean.id(), containerBookingId)) {
@@ -111,8 +111,7 @@ public class PlatformCarrierScheduleQueryController {
@PostMapping("/{queryId}/manual-nodes")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
@Operation(summary = "人工补录预计或实际标准船节点",
description = "预计节点不推进实际进度;人工实际节点必须严格晚于当前最新实际节点,同级或更早节点返回 A0409。")
@Operation(summary = "在跟踪任务内人工补录预计或实际标准船节点")
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY",
resourceId = "#queryId", description = "人工补录船节点事实")
public ApiResponse<Detail> manualNode(@AuthenticationPrincipal AuthenticatedUser user,
@@ -0,0 +1,44 @@
package com.baiyue.firstleg.portschedule.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerNodeDetail;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
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.*;
@Validated
@RestController
@RequestMapping("/api/platform/v1/container-shipping-nodes")
public class PlatformContainerShippingNodeController {
@Resource private CarrierScheduleQueryService service;
@GetMapping("/{containerBookingId}")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')")
public ApiResponse<ContainerNodeDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.containerNodeDetail(containerBookingId));
}
@PostMapping("/{containerBookingId}")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
@BusinessAudit(event = "CONTAINER_SHIPPING_NODE_MANUAL", resourceType = "CONTAINER_BOOKING",
resourceId = "#containerBookingId", description = "人工修改货柜当前船节点")
public ApiResponse<ContainerNodeDetail> add(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ContainerManualNodeRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.addContainerManualNode(containerBookingId, user.userId(),
user.username(), key, request));
}
}
@@ -13,7 +13,7 @@ import java.util.List;
public final class CarrierScheduleQueryDtos {
private CarrierScheduleQueryDtos() {}
public record Query(@Positive Long customerId, @Positive Long oceanBookingId,
public record Query(@Min(0) Long customerId, @Positive Long oceanBookingId,
@Positive Long containerBookingId, @Size(max = 16) String carrierCode,
@Size(max = 24) String status, Boolean expired,
@Size(max = 128) String keyword, @Min(1) Long page,
@@ -22,7 +22,7 @@ public final class CarrierScheduleQueryDtos {
}
public record CreateRequest(@NotNull @Min(0) @Max(0) Integer expectedVersion,
@NotNull @Positive Long customerId,
@NotNull @Min(0) Long customerId,
@NotNull @Positive Long oceanBookingId,
@NotNull @Positive Long containerBookingId,
@NotBlank @Size(max = 16) String carrierCode,
@@ -43,6 +43,14 @@ public final class CarrierScheduleQueryDtos {
@Size(max = 128) String locationName,
@NotBlank @Size(max = 1000) String reason) {}
public record ContainerManualNodeRequest(@NotBlank @Size(max = 32) String standardNode,
@NotNull LocalDateTime observedAt,
LocalDateTime estimatedAt,
LocalDateTime actualAt,
@Size(max = 32) String locationCode,
@Size(max = 128) String locationName,
@NotBlank @Size(max = 1000) String reason) {}
public record ProviderObservationRequest(@NotBlank @Size(max = 32) String providerCode,
@NotBlank @Size(max = 128) String providerEventId,
@NotBlank @Size(max = 64) String providerNodeCode,
@@ -97,6 +105,9 @@ public final class CarrierScheduleQueryDtos {
LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {}
public record ContainerNodeDetail(Long containerBookingId, String currentNode,
LocalDateTime currentNodeAt, List<NodeFact> nodes) {}
public record ExceptionFact(Long id, String providerCode, String providerEventId,
String providerStatus, String exceptionCode, boolean active,
LocalDateTime observedAt, String locationCode, String locationName,
@@ -16,6 +16,7 @@ import java.time.LocalDateTime;
public class CarrierScheduleNodeFactEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long queryId;
private Long containerBookingId;
private String eventKey;
private String providerCode;
private String providerEventId;
@@ -5,19 +5,29 @@ import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
@Insert("""
INSERT INTO fl_carrier_schedule_node_fact
(id,query_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at,
(id,query_id,container_booking_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at,
estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at)
VALUES
(#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode},
(#{value.id},#{value.queryId},#{value.containerBookingId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode},
#{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt},
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},
CURRENT_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE id=id
""")
int insertIgnore(@Param("value") CarrierScheduleNodeFactEntity value);
@Select("SELECT * FROM fl_carrier_schedule_node_fact WHERE container_booking_id=#{containerBookingId} ORDER BY observed_at,id")
List<CarrierScheduleNodeFactEntity> selectByContainer(@Param("containerBookingId") long containerBookingId);
@Select("SELECT * FROM fl_carrier_schedule_node_fact WHERE container_booking_id=#{containerBookingId} AND event_key=#{eventKey} LIMIT 1")
CarrierScheduleNodeFactEntity selectByContainerEvent(@Param("containerBookingId") long containerBookingId,
@Param("eventKey") String eventKey);
}
@@ -49,26 +49,26 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
@Select("""
<script>
SELECT q.container_booking_id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
q.status AS queryStatus,q.latest_standard_node AS latestActualNode,
q.latest_node_observed_at AS latestActualAt,
SELECT b.id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
q.status AS queryStatus,current.standard_node AS latestActualNode,
current.actual_at AS latestActualAt,
MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE' THEN f.actual_at END) AS departedWarehouseAt,
MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) AS departedOriginPortAt,
MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT' THEN f.actual_at END) AS arrivedDestinationPortAt,
MAX(CASE WHEN f.standard_node='PICKUP_READY' THEN f.actual_at END) AS pickupReadyAt,
MAX(CASE WHEN f.standard_node='PICKED_UP' THEN f.actual_at END) AS pickedUpAt,
MAX(CASE WHEN f.standard_node='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt
FROM fl_carrier_schedule_query q
JOIN (
SELECT container_booking_id,MAX(id) AS query_id
FROM fl_carrier_schedule_query
WHERE container_booking_id IN
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
GROUP BY container_booking_id
) latest ON latest.query_id=q.id
LEFT JOIN fl_carrier_schedule_node_fact f ON f.query_id=q.id AND f.actual_at IS NOT NULL
GROUP BY q.container_booking_id,q.id,q.version,q.status,q.latest_standard_node,q.latest_node_observed_at
ORDER BY q.container_booking_id
FROM fl_container_booking b
JOIN fl_carrier_schedule_node_fact current ON current.id=(
SELECT f2.id FROM fl_carrier_schedule_node_fact f2
WHERE f2.container_booking_id=b.id AND f2.actual_at IS NOT NULL
ORDER BY f2.observed_at DESC,f2.id DESC LIMIT 1)
LEFT JOIN fl_carrier_schedule_query q ON q.id=current.query_id
LEFT JOIN fl_carrier_schedule_node_fact f ON f.container_booking_id=b.id AND f.actual_at IS NOT NULL
WHERE b.id IN
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
GROUP BY b.id,q.id,q.version,q.status,current.standard_node,current.actual_at
ORDER BY b.id
</script>
""")
List<ContainerActualFacts> selectContainerActualFacts(
@@ -79,6 +79,12 @@ public class CarrierScheduleQueryRepository {
.eq(CarrierScheduleNodeFactEntity::getQueryId, queryId)
.eq(CarrierScheduleNodeFactEntity::getEventKey, eventKey));
}
public List<CarrierScheduleNodeFactEntity> nodesByContainer(long containerBookingId) {
return nodes.selectByContainer(containerBookingId);
}
public CarrierScheduleNodeFactEntity nodeByContainer(long containerBookingId, String eventKey) {
return nodes.selectByContainerEvent(containerBookingId, eventKey);
}
public boolean hasActualNode(long queryId, String standardNode) {
return nodes.selectCount(new LambdaQueryWrapper<CarrierScheduleNodeFactEntity>()
.eq(CarrierScheduleNodeFactEntity::getQueryId, queryId)
@@ -42,28 +42,40 @@ public class CarrierScheduleNodeOutboxService {
|| !normalizedContainerNo.equals(query.getTrackingNo())) {
throw new BizException(ResultCode.CONFLICT, "船节点任务跟踪柜号与跨区事件柜号不一致");
}
publish(query.getId(), query.getOceanBookingId(), query.getContainerBookingId(), query.getVersion(),
fact, normalizedContainerNo, "CARRIER_NODE:" + query.getId() + ':' + fact.getId());
}
public void publishContainerNode(Long oceanBookingId, long containerBookingId,
CarrierScheduleNodeFactEntity fact, String containerNo) {
if (fact.getActualAt() == null) return;
String normalizedContainerNo = ContainerNumbers.normalizeRequired(containerNo);
publish(null, oceanBookingId, containerBookingId, 0, fact, normalizedContainerNo,
"CONTAINER_NODE:" + containerBookingId + ':' + fact.getId());
}
private void publish(Long queryId, Long oceanBookingId, Long containerBookingId, Integer version,
CarrierScheduleNodeFactEntity fact, String normalizedContainerNo, String eventKey) {
ZoneId zone = ZoneId.of(scheduleZone);
var payload = new FirstLegContainerShippingNodeRecordedV1(
string(query.getId()), string(query.getOceanBookingId()),
string(query.getContainerBookingId()), normalizedContainerNo,
string(queryId), string(oceanBookingId), string(containerBookingId), normalizedContainerNo,
fact.getStandardNode(), fact.getNodeSequence(),
fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(),
fact.getLocationCode(), fact.getLocationName(), fact.getProviderCode(), fact.getProviderEventId(),
fact.getProviderNodeCode(), fact.getSourceReference(), fact.getRawPayloadHash());
JsonNode payloadNode = objectMapper.valueToTree(payload);
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
long aggregateVersion = Math.max(0, query.getVersion() == null ? 0 : query.getVersion());
String eventKey = "CARRIER_NODE:" + query.getId() + ':' + fact.getId();
long aggregateVersion = Math.max(0, version == null ? 0 : version);
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1,
fact.getActualAt().atZone(zone).toInstant(), RegionCode.CN, RegionCode.OVERSEAS,
"0", "CONTAINER_BOOKING", string(query.getContainerBookingId()),
"0", "CONTAINER_BOOKING", string(containerBookingId),
aggregateVersion, aggregateVersion + 1, EventIdentifiers.newUuid(), eventKey, null,
payloadNode, payloadHash).requireValid();
InboundOutboxEntity row = new InboundOutboxEntity();
row.setId(IdWorker.getId()); row.setTenantId(0L);
row.setAggregateType(envelope.aggregateType()); row.setAggregateId(query.getContainerBookingId());
row.setAggregateType(envelope.aggregateType()); row.setAggregateId(containerBookingId);
row.setEventType(envelope.eventType()); row.setEventKey(eventKey); row.setEventVersion(1);
row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash);
row.setPayloadJson(json(envelope)); row.setPublishStatus("PENDING"); row.setRetryCount(0);
@@ -8,6 +8,8 @@ import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerNodeDetail;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
@@ -48,6 +50,7 @@ import java.security.MessageDigest;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.HashSet;
import java.util.List;
@@ -101,15 +104,17 @@ public class CarrierScheduleQueryService {
/** Idempotently starts the canonical tracking task after domestic outbound confirmation. */
@Transactional
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(long customerId, long oceanBookingId,
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId,
long containerBookingId,
long operatorId, String operatorName) {
CarrierScheduleQueryEntity existing = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId);
OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId);
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在");
validateLinks(customerId, oceanBookingId, containerBookingId);
long scopeCustomerId = booking.getCustomerId() == null ? 0L : booking.getCustomerId();
if (customerId != null && customerId != scopeCustomerId) throw badRequest("customerId与订舱客户不一致");
CarrierScheduleQueryEntity existing = repository.findByBusinessScope(scopeCustomerId, oceanBookingId,
containerBookingId);
validateLinks(scopeCustomerId, oceanBookingId, containerBookingId);
String trackingType = "CONTAINER_NO";
String trackingNo = normalizedContainerNo(container);
if (existing != null) {
@@ -120,7 +125,7 @@ public class CarrierScheduleQueryService {
LocalDateTime now = now();
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId());
value.setCustomerId(customerId); value.setOceanBookingId(oceanBookingId);
value.setCustomerId(scopeCustomerId); value.setOceanBookingId(oceanBookingId);
value.setContainerBookingId(containerBookingId); value.setCarrierCode(strategy.carrierCode());
value.setTrackingType(trackingType); value.setTrackingNo(trackingNo);
value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode());
@@ -136,7 +141,7 @@ public class CarrierScheduleQueryService {
try {
try { repository.insert(value); }
catch (DuplicateKeyException exception) {
CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId,
CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(scopeCustomerId, oceanBookingId,
containerBookingId);
if (concurrent != null) {
requireFrozenTrackingIdentity(concurrent, trackingNo);
@@ -145,7 +150,7 @@ public class CarrierScheduleQueryService {
throw exception;
}
persistenceStage = "NODE_FACT";
CarrierScheduleNodeFactEntity departed = addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
CarrierScheduleNodeFactEntity departed = addNode(value, "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
sha256("OUTBOUND_CONFIRM|" + containerBookingId));
if (departed != null) {
@@ -176,7 +181,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleQueryEntity value = requiredLocked(id);
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
CarrierScheduleQueryEntity before = copy(value);
CarrierScheduleNodeFactEntity fact = addNode(id, request.providerCode(), request.providerEventId(),
CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(),
request.providerNodeCode(), node,
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
request.locationName(), request.sourceReference(), request.rawPayloadHash());
@@ -217,10 +222,9 @@ public class CarrierScheduleQueryService {
}
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
requireManualActualAdvance(value, node, request.actualAt());
CarrierScheduleQueryEntity before = copy(value);
String providerEventId = sha256("MANUAL|" + id + '|' + key);
CarrierScheduleNodeFactEntity fact = addNode(id, "MANUAL", providerEventId, node.name(), node,
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
request.observedAt(),
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
"MANUAL:" + operatorId, hash(operation, id, request));
@@ -239,13 +243,42 @@ public class CarrierScheduleQueryService {
return complete(id, operatorId, key, detail(after));
}
private void requireManualActualAdvance(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
LocalDateTime actualAt) {
if (actualAt == null || value.getLatestStandardNode() == null) return;
CarrierScheduleNode current = CarrierScheduleNode.require(value.getLatestStandardNode());
if (node.sequence() <= current.sequence()) {
throw conflict("人工实际节点必须严格晚于当前最新实际节点");
public ContainerNodeDetail containerNodeDetail(long containerBookingId) {
if (portSchedules.containerBooking(containerBookingId) == null) {
throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
}
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
}
@Transactional
public ContainerNodeDetail addContainerManualNode(long containerBookingId, long operatorId,
String operatorName, String key,
ContainerManualNodeRequest request) {
validateKey(key);
if (request.estimatedAt() == null && request.actualAt() == null) {
throw badRequest("estimatedAt和actualAt至少提供一个");
}
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
if (container == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
String providerEventId = sha256("CONTAINER_MANUAL|" + containerBookingId + '|' + key);
String payloadHash = hash("CONTAINER_MANUAL_NODE", containerBookingId, request);
CarrierScheduleNodeFactEntity fact = addNode(null, containerBookingId, "MANUAL", providerEventId,
node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(),
request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash);
if (fact != null) nodeOutbox.publishContainerNode(container.getOceanBookingId(), containerBookingId,
fact, normalizedContainerNo(container));
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
}
private ContainerNodeDetail containerNodeDetail(long containerBookingId,
List<CarrierScheduleNodeFactEntity> facts) {
CarrierScheduleNodeFactEntity current = facts.stream()
.max(Comparator.comparing(CarrierScheduleNodeFactEntity::getObservedAt)
.thenComparing(CarrierScheduleNodeFactEntity::getId)).orElse(null);
return new ContainerNodeDetail(containerBookingId, current == null ? null : current.getStandardNode(),
current == null ? null : current.getActualAt() != null ? current.getActualAt() : current.getEstimatedAt(),
facts.stream().map(this::node).toList());
}
private boolean advanceActualNode(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
@@ -424,7 +457,8 @@ public class CarrierScheduleQueryService {
private void validateLinks(long customerId, long oceanBookingId, Long containerBookingId) {
OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId);
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "关联订舱不存在");
if (!Objects.equals(booking.getCustomerId(), customerId)) throw badRequest("customerId与订舱客户不一致");
long expectedCustomerId = booking.getCustomerId() == null ? 0L : booking.getCustomerId();
if (expectedCustomerId != customerId) throw badRequest("customerId与订舱客户不一致");
if (containerBookingId != null) {
if (portSchedules.containerBooking(containerBookingId) == null) {
throw new BizException(ResultCode.NOT_FOUND, "关联柜次不存在");
@@ -490,13 +524,23 @@ public class CarrierScheduleQueryService {
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
}
private CarrierScheduleNodeFactEntity addNode(long queryId, String providerCode, String providerEventId,
private CarrierScheduleNodeFactEntity addNode(CarrierScheduleQueryEntity query, String providerCode, String providerEventId,
String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) {
return addNode(query.getId(), query.getContainerBookingId(), providerCode, providerEventId,
providerNodeCode, node, observedAt, estimatedAt, actualAt, locationCode, locationName,
sourceReference, rawPayloadHash);
}
private CarrierScheduleNodeFactEntity addNode(Long queryId, long containerBookingId,
String providerCode, String providerEventId, String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) {
CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity();
fact.setId(IdWorker.getId()); fact.setQueryId(queryId);
fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setContainerBookingId(containerBookingId);
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT));
fact.setProviderEventId(providerEventId.trim());
@@ -505,7 +549,7 @@ public class CarrierScheduleQueryService {
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
if (repository.insertIfAbsent(fact)) return fact;
CarrierScheduleNodeFactEntity existing = repository.node(queryId, fact.getEventKey());
CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey());
if (existing != null && !Objects.equals(existing.getRawPayloadHash(), rawPayloadHash)) {
throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有节点事实");
}
@@ -55,6 +55,19 @@ public class FbaPricingRepository {
public void insertRule(CustomerChargeRuleEntity rule) { rules.insert(rule); }
public CustomerChargeRuleEntity findRuleByInitializationKey(long tenantId, String initializationKey) {
return rules.selectByInitializationKey(tenantId, initializationKey);
}
public CustomerChargeRuleEntity findRuleByInitializationKeyForUpdate(
long tenantId, String initializationKey) {
return rules.selectByInitializationKeyForUpdate(tenantId, initializationKey);
}
public int deleteUnchangedInitializedRule(long tenantId, String initializationKey) {
return rules.deleteUnchangedInitializedRule(tenantId, initializationKey);
}
public int clearDefaultRule(long tenantId, long customerId, long operatorId) {
return rules.update(null, new LambdaUpdateWrapper<CustomerChargeRuleEntity>()
.eq(CustomerChargeRuleEntity::getTenantId, tenantId)
@@ -285,22 +298,22 @@ public class FbaPricingRepository {
.set(InboundFeeEntity::getUpdatedAt, LocalDateTime.now()));
}
public int markInboundAdjusted(long tenantId, long inboundId, String feeStatus,
public int markInboundAdjusted(long tenantId, long inboundId, String processStatus, String feeStatus,
java.math.BigDecimal selectedWeightKg,
java.math.BigDecimal selectedVolumeCbm,
String chargeUnit, java.math.BigDecimal chargeableQuantity) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
.eq(InboundPricingEntity::getId, inboundId)
.eq(InboundPricingEntity::getProcessStatus, "WAIT_SPLIT")
.eq(InboundPricingEntity::getProcessStatus, processStatus)
.set(InboundPricingEntity::getFeeStatus, feeStatus);
if ("KG".equals(chargeUnit)) {
update.set(InboundPricingEntity::getChargeableWeightKg, chargeableQuantity);
} else {
update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeableQuantity);
}
applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeUnit, chargeableQuantity);
return inbounds.update(null, update);
}
public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode,
java.math.BigDecimal selectedWeightKg,
java.math.BigDecimal selectedVolumeCbm,
java.math.BigDecimal chargeable) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
@@ -308,15 +321,17 @@ public class FbaPricingRepository {
.eq(InboundPricingEntity::getProcessStatus, processStatus)
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED", "SENT"))
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
if (!List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING").contains(processStatus)) {
if (!List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")
.contains(processStatus)) {
update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING");
}
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable);
else update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeable);
applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeMode, chargeable);
return inbounds.update(null, update);
}
public int markInboundContainerFeeRecalculated(long tenantId, long inboundId, String processStatus,
java.math.BigDecimal selectedWeightKg,
java.math.BigDecimal selectedVolumeCbm,
String chargeMode, java.math.BigDecimal chargeable) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
@@ -324,11 +339,21 @@ public class FbaPricingRepository {
.eq(InboundPricingEntity::getProcessStatus, processStatus)
.in(InboundPricingEntity::getFeeStatus, List.of("CALCULATED", "CONFIRMED"))
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable);
else update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeable);
applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeMode, chargeable);
return inbounds.update(null, update);
}
private void applyChargeableSnapshot(LambdaUpdateWrapper<InboundPricingEntity> update,
java.math.BigDecimal selectedWeightKg,
java.math.BigDecimal selectedVolumeCbm,
String chargeUnit, java.math.BigDecimal chargeableQuantity) {
// 列表同时展示两项:当前计费单位保存最终计费量,另一项保存本次计价选定值。
update.set(InboundPricingEntity::getChargeableWeightKg,
"KG".equals(chargeUnit) ? chargeableQuantity : selectedWeightKg)
.set(InboundPricingEntity::getChargeableVolumeCbm,
"CBM".equals(chargeUnit) ? chargeableQuantity : selectedVolumeCbm);
}
public int confirmFee(long tenantId, long feeId, long operatorId, LocalDateTime now) {
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId).eq(InboundFeeEntity::getId, feeId)
@@ -71,6 +71,14 @@ public class InboundMeasurementRepository {
.eq(InboundMeasurementEntity::getId, measurementId));
}
public InboundMeasurementEntity lockMeasurement(long tenantId, long inboundId, long measurementId) {
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
.eq(InboundMeasurementEntity::getTenantId, tenantId)
.eq(InboundMeasurementEntity::getInboundId, inboundId)
.eq(InboundMeasurementEntity::getId, measurementId)
.last("FOR UPDATE"));
}
public InboundMeasurementEntity findLatestMeasurement(long tenantId, long inboundId) {
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
.eq(InboundMeasurementEntity::getTenantId, tenantId)
@@ -0,0 +1,95 @@
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.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 在客户创建执行器中幂等生成“箱规按权重系数计算”的默认规则。 */
@Service
public class CustomerChargeRuleInitializationService {
static final String DEFAULT_RULE_CODE = "DEFAULT-DIMENSION-WEIGHT";
static final BigDecimal DEFAULT_COEFFICIENT = new BigDecimal("1.100000");
static final BigDecimal DEFAULT_CONVERSION_FACTOR = new BigDecimal("167.000000");
static final String DEFAULT_REMARK = "SMALL:30,20,20;系数:1.1;重量:5;小箱:标准小箱;"
+ "MEDIUM:40,30,30;系数:1.1;重量:8;中箱:标准中箱;"
+ "LARGE:50,40,40;系数:1.1;重量:12;大箱:标准大箱;"
+ "EXTRA_LARGE:60,50,50;系数:1.1;重量:15;特大箱:特大箱";
@Resource private FbaPricingRepository repository;
public CustomerChargeRuleEntity initializeDefault(long tenantId, long operatorId,
String initializationKey, String customerName) {
CustomerChargeRuleEntity existing = repository.findRuleByInitializationKey(
tenantId, initializationKey);
if (existing != null) return existing;
CustomerChargeRuleEntity value = new CustomerChargeRuleEntity();
value.setId(IdWorker.getId());
value.setTenantId(tenantId);
value.setRuleCode(DEFAULT_RULE_CODE);
value.setRuleVersion(1);
value.setCustomerId(tenantId);
value.setCustomerName(requireCustomerName(customerName));
value.setBusinessType("FBA");
value.setChargeMode("CBM");
value.setTaxMode("TAX_INCLUDED");
value.setConversionFactor(DEFAULT_CONVERSION_FACTOR);
value.setLengthFactor(DEFAULT_COEFFICIENT);
value.setWidthFactor(DEFAULT_COEFFICIENT);
value.setHeightFactor(DEFAULT_COEFFICIENT);
value.setDefaultRule(true);
value.setRoundingScale(2);
value.setRoundingMode("HALF_UP");
value.setCurrency("CNY");
value.setEffectiveFrom(LocalDateTime.now());
value.setEffectiveTo(null);
value.setStatus("ACTIVE");
value.setRemark(DEFAULT_REMARK);
value.setInitializationKey(initializationKey);
value.setCreatedBy(operatorId);
value.setUpdatedBy(operatorId);
repository.insertRule(value);
return value;
}
CustomerChargeRuleEntity lockForCompensation(long tenantId, String initializationKey) {
return repository.findRuleByInitializationKeyForUpdate(tenantId, initializationKey);
}
boolean isUnchangedDefault(CustomerChargeRuleEntity value) {
return value != null
&& value.getVersion() != null && value.getVersion() == 0
&& DEFAULT_RULE_CODE.equals(value.getRuleCode()) && Integer.valueOf(1).equals(value.getRuleVersion())
&& value.getTenantId().equals(value.getCustomerId())
&& "FBA".equals(value.getBusinessType()) && "CBM".equals(value.getChargeMode())
&& "TAX_INCLUDED".equals(value.getTaxMode())
&& DEFAULT_CONVERSION_FACTOR.compareTo(value.getConversionFactor()) == 0
&& DEFAULT_COEFFICIENT.compareTo(value.getLengthFactor()) == 0
&& DEFAULT_COEFFICIENT.compareTo(value.getWidthFactor()) == 0
&& DEFAULT_COEFFICIENT.compareTo(value.getHeightFactor()) == 0
&& Boolean.TRUE.equals(value.getDefaultRule()) && Integer.valueOf(2).equals(value.getRoundingScale())
&& "HALF_UP".equals(value.getRoundingMode()) && "CNY".equals(value.getCurrency())
&& value.getEffectiveTo() == null && "ACTIVE".equals(value.getStatus())
&& DEFAULT_REMARK.equals(value.getRemark());
}
void deleteUnchangedDefault(long tenantId, String initializationKey) {
if (repository.deleteUnchangedInitializedRule(tenantId, initializationKey) != 1) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户权重系数默认规则补偿删除失败");
}
}
private static String requireCustomerName(String value) {
if (value == null || value.isBlank() || value.trim().length() > 128) {
throw new BizException(ResultCode.BAD_REQUEST, "客户名称无效");
}
return value.trim();
}
}
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.RuleResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.RuleUpdateRequest;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest;
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
@@ -32,6 +33,8 @@ public class CustomerDimensionConfigService {
@Resource
private CustomerDimensionConfigRepository repository;
@Resource
private CustomerChargeRuleInitializationService chargeRuleInitialization;
public GroupResponse get(long tenantId) {
return response(tenantId, requireCompleteRules(repository.findAll(tenantId)));
@@ -78,18 +81,26 @@ public class CustomerDimensionConfigService {
}
@Transactional
public InitializeResponse initializeDefault(long tenantId, long operatorId, String initializationKey) {
public InitializeResponse initializeDefault(long tenantId, long operatorId, String initializationKey,
String customerName) {
requireTenant(tenantId);
String normalizedKey = requireInitializationKey(initializationKey);
List<CustomerDimensionConfigEntity> existing = repository.findAll(tenantId);
if (!existing.isEmpty()) {
List<CustomerDimensionConfigEntity> complete = requireCompleteRules(existing);
return initializationResponse(complete, normalizedKey);
boolean ownedByKey = ownedByInitialization(complete, normalizedKey);
CustomerChargeRuleEntity chargeRule = ownedByKey
? chargeRuleInitialization.initializeDefault(
tenantId, operatorId, normalizedKey, customerName)
: null;
return initializationResponse(complete, chargeRule, ownedByKey);
}
repository.insertDefaults(defaultRows(tenantId, normalizedKey), operatorId);
List<CustomerDimensionConfigEntity> persisted = requireCompleteRules(repository.findAll(tenantId));
return initializationResponse(persisted, normalizedKey);
CustomerChargeRuleEntity chargeRule = chargeRuleInitialization.initializeDefault(
tenantId, operatorId, normalizedKey, customerName);
return initializationResponse(persisted, chargeRule, true);
}
@Transactional
@@ -99,6 +110,12 @@ public class CustomerDimensionConfigService {
List<CustomerDimensionConfigEntity> locked =
repository.findDefaultsForCompensation(tenantId, normalizedKey);
if (!isUnchangedDefaultGroup(locked, normalizedKey)) return;
CustomerChargeRuleEntity chargeRule = chargeRuleInitialization.lockForCompensation(
tenantId, normalizedKey);
if (chargeRule != null && !chargeRuleInitialization.isUnchangedDefault(chargeRule)) return;
if (chargeRule != null) {
chargeRuleInitialization.deleteUnchangedDefault(tenantId, normalizedKey);
}
if (repository.deleteUnchangedDefaults(tenantId, normalizedKey) != CustomerDimensionBoxType.values().length) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户箱规默认配置补偿删除失败");
}
@@ -206,11 +223,16 @@ public class CustomerDimensionConfigService {
return true;
}
private static boolean ownedByInitialization(List<CustomerDimensionConfigEntity> values,
String initializationKey) {
return values.stream().allMatch(value -> initializationKey.equals(value.getInitializationKey()));
}
private static InitializeResponse initializationResponse(List<CustomerDimensionConfigEntity> values,
String initializationKey) {
boolean ownedByKey = values.stream()
.allMatch(value -> initializationKey.equals(value.getInitializationKey()));
return new InitializeResponse(values.get(0).getId(), ownedByKey);
CustomerChargeRuleEntity chargeRule,
boolean ownedByKey) {
return new InitializeResponse(values.get(0).getId(),
chargeRule == null ? null : chargeRule.getId(), ownedByKey);
}
private static GroupResponse response(long tenantId, List<CustomerDimensionConfigEntity> values) {
@@ -44,7 +44,6 @@ public class FbaPricingService {
private static final BigDecimal ADJUST_TOLERANCE_PERCENT = new BigDecimal("10.00");
private static final BigDecimal ONE_HUNDRED = new BigDecimal("100");
private static final long AUTOMATIC_CONFIRM_OPERATOR_ID = 0L;
private static final String AUTOMATIC_CONFIRM_OPERATOR_NAME = "装柜完成自动确认";
private static final String CONTAINER_RECALCULATE_OPERATION = "CONTAINER_FEE_RECALCULATE";
@Resource
private FbaPricingRepository repository;
@@ -99,8 +98,8 @@ public class FbaPricingService {
if (published == null || !"SENT".equals(published.getFeeStatus())) {
throw conflict("费用状态已变化,请刷新后重试");
}
if (published.getCustomerConfirmedAt() != null) {
throw conflict("客户已确认该费用,不允许再修改或重新发送");
if (feeRevisionLocked(inbound, published)) {
throw conflict("客户已确认且该票已装柜完成,不允许再修改或重新发送费用");
}
}
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
@@ -167,7 +166,7 @@ public class FbaPricingService {
repository.insertItem(baseItem(tenantId, fee, rate, chargeable, subtotal, now));
if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, fee, additional, now));
if (repository.markInboundCalculated(tenantId, inboundId, inbound.getProcessStatus(),
estimate.chargeUnit(), chargeable) != 1) {
estimate.chargeUnit(), weight, volume, chargeable) != 1) {
throw conflict("入库单状态已变化,费用试算未保存");
}
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
@@ -343,7 +342,8 @@ public class FbaPricingService {
recalculated.getSubtotalAmount(), now));
if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, recalculated, additional, now));
if (repository.markInboundContainerFeeRecalculated(tenantId, member.inbound().getId(),
member.inbound().getProcessStatus(), estimate.chargeUnit(), member.chargeableQuantity()) != 1) {
member.inbound().getProcessStatus(), member.current().getActualWeightKg(),
member.current().getActualVolumeCbm(), estimate.chargeUnit(), member.chargeableQuantity()) != 1) {
throw conflict("柜内入库单状态已变化,不能重算阶梯费用");
}
feeDetails.materializeCalculatedFreight(tenantId, member.inbound().getId(), recalculated.getId(),
@@ -390,8 +390,8 @@ public class FbaPricingService {
public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId,
String idempotencyKey) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!feeEditableBeforeLoading(inbound.getProcessStatus())) {
throw conflict("订单已进入装柜流程,不能再修改或发送费用");
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
throw conflict("当前入库单状态不允许确认费用");
}
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
if (current == null) throw conflict("入库单尚无可确认的费用试算");
@@ -412,8 +412,8 @@ public class FbaPricingService {
public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId,
String idempotencyKey) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!feeEditableBeforeLoading(inbound.getProcessStatus())) {
throw conflict("订单已进入装柜流程,不能再修改或发送费用");
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
throw conflict("当前入库单状态不允许发送费用");
}
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
if (current == null) throw conflict("入库单尚无费用快照");
@@ -445,21 +445,6 @@ public class FbaPricingService {
return response(tenantId, fee.getId(), null);
}
/** 客户未操作时,装柜完成以系统身份确认最新一版已发送费用。 */
@Transactional
public void autoConfirmAfterLoading(long tenantId, long inboundId) {
InboundFeeEntity current = repository.findLatestFeeForUpdate(tenantId, inboundId);
if (current == null || !"SENT".equals(current.getFeeStatus())
|| current.getCustomerConfirmedAt() != null) {
return;
}
LocalDateTime now = LocalDateTime.now(clock);
if (repository.confirmFeeByCustomer(tenantId, current.getId(), current.getFeeVersion(),
AUTOMATIC_CONFIRM_OPERATOR_ID, AUTOMATIC_CONFIRM_OPERATOR_NAME, now) != 1) {
throw conflict("装柜完成自动确认费用失败,请重试");
}
}
@Transactional
public FeeSnapshotResponse confirmByCustomer(long tenantId, long operatorId, String operatorName,
long inboundId, int expectedFeeVersion,
@@ -498,15 +483,15 @@ public class FbaPricingService {
AdjustFeeRequest request, String idempotencyKey) {
InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
if (!isCompletedInbound(inbound.getProcessStatus())) {
throw conflict("只能在待分柜阶段修改初算或已发布费用");
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
throw conflict("当前入库单状态不允许修改初算或已发布费用");
}
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
if (current == null || !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) {
throw conflict("入库单没有可修改的初算或已发布费用");
}
if (current.getCustomerConfirmedAt() != null) {
throw conflict("客户已确认该费用,不允许再修改");
if (feeRevisionLocked(inbound, current)) {
throw conflict("客户已确认且该票已装柜完成,不允许再修改费用");
}
if (!request.expectedFeeVersion().equals(current.getFeeVersion())) {
throw conflict("费用版本已变化,请刷新后重试");
@@ -554,6 +539,9 @@ public class FbaPricingService {
adjusted.setConfirmedAt(published ? now : null);
adjusted.setSentBy(published ? operatorId : null);
adjusted.setSentAt(published ? now : null);
adjusted.setCustomerConfirmedBy(null);
adjusted.setCustomerConfirmedByName(null);
adjusted.setCustomerConfirmedAt(null);
adjusted.setCreatedAt(now);
adjusted.setUpdatedAt(now);
if (repository.supersedeCurrent(tenantId, current.getId(), current.getFeeStatus()) != 1) {
@@ -576,8 +564,8 @@ public class FbaPricingService {
extra.setRemark(request.reason().trim());
repository.insertItem(extra);
}
if (repository.markInboundAdjusted(tenantId, inboundId, adjusted.getFeeStatus(),
adjusted.getChargeUnit(), quantity) != 1) {
if (repository.markInboundAdjusted(tenantId, inboundId, inbound.getProcessStatus(), adjusted.getFeeStatus(),
adjusted.getActualWeightKg(), adjusted.getActualVolumeCbm(), adjusted.getChargeUnit(), quantity) != 1) {
throw conflict("入库单状态已变化,费用调整未保存");
}
if (published) {
@@ -594,7 +582,7 @@ public class FbaPricingService {
private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!feeEditableBeforeLoading(inbound.getProcessStatus())
if (!feeRevisionStatusSupported(inbound.getProcessStatus())
|| !List.of("UNPRICED", "CALCULATED", "SENT").contains(inbound.getFeeStatus())) {
throw conflict("当前入库单状态不允许费用试算");
}
@@ -613,20 +601,23 @@ public class FbaPricingService {
return inbound;
}
private static boolean isCompletedInbound(String processStatus) {
return "WAIT_SPLIT".equals(processStatus);
}
private static boolean preserveProcessStatusDuringPricing(String processStatus) {
return List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING").contains(processStatus);
return List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")
.contains(processStatus);
}
private static boolean feeEditableBeforeLoading(String processStatus) {
private static boolean feeRevisionStatusSupported(String processStatus) {
return List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
"WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING")
"WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING",
"LOADED", "DELIVERED")
.contains(processStatus);
}
private static boolean feeRevisionLocked(InboundPricingEntity inbound, InboundFeeEntity fee) {
return fee != null && fee.getCustomerConfirmedAt() != null
&& List.of("LOADED", "DELIVERED").contains(inbound.getProcessStatus());
}
private static PricingDestinationType destinationType(InboundPricingEntity inbound) {
try {
return PricingDestinationType.valueOf(inbound.getDestinationType());
@@ -92,6 +92,27 @@ public class InboundCartonLabelPrintService {
return ResponseEntity.status(response.getStatusCode()).headers(headers).body(response.getBody());
}
public ResponseEntity<byte[]> generateForPlatformInbounds(
List<InboundCartonLabels.PlatformBatchItem> items, String requestId) {
if (items == null || items.isEmpty() || items.size() > 200
|| items.stream().anyMatch(item -> item == null || item.tenantId() <= 0 || item.inboundId() <= 0)) {
throw new BizException(ResultCode.BAD_REQUEST, "批量打印必须选择1至200个入库单");
}
List<InboundCartonLabels.PlatformBatchItem> distinctItems = items.stream().distinct().toList();
List<InboundCartonLabels.Label> labels = new ArrayList<>();
for (InboundCartonLabels.PlatformBatchItem item : distinctItems) {
labels.addAll(labelService.list(item.tenantId(), item.inboundId(), null).labels());
}
if (labels.isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "所选订单没有可生成的箱唛");
}
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(distinctItems.size()));
return ResponseEntity.status(response.getStatusCode()).headers(headers).body(response.getBody());
}
/** 一件代发托盘标签:托盘号、入仓码、SKU箱数和托盘二维码。 */
public ResponseEntity<byte[]> generateDropshipPallet(Pallet pallet, String requestId) {
if (pallet == null || !StringUtils.hasText(pallet.palletNo()) || pallet.items().isEmpty()) {
@@ -12,6 +12,7 @@ import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundCartonLabelRepository;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -117,7 +118,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
throw new BizException(ResultCode.INTERNAL_ERROR, "预入库单补建后未能读取");
}
return prepared;
} catch (FeignException exception) {
} catch (FeignException | NoFallbackAvailableException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法补建预入库单");
}
}
@@ -181,7 +182,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
.thenComparing(OrderCargoClient.CargoLabelBinding::cargoId,
java.util.Comparator.nullsLast(Long::compareTo)))
.toList();
} catch (FeignException exception) {
} catch (FeignException | NoFallbackAvailableException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法生成唛头");
}
}
@@ -20,6 +20,7 @@ import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -61,6 +62,8 @@ public class InboundMeasurementService {
@Resource
private MeasurementBatchService batchService;
@Resource
private FbaPricingRepository pricingRepository;
@Resource
private FbaMasterDataService fbaMasterDataService;
@Resource
private OrderCargoClient orderCargoClient;
@@ -162,6 +165,68 @@ public class InboundMeasurementService {
return new InboundMeasurementResponses.DraftSaved(saved.getId(), saved.getVersion(), saved.getStatus());
}
@Transactional
public InboundMeasurementResponses.CalculationStrategyUpdated updateCalculationStrategy(
long tenantId, long inboundId,
InboundMeasurementRequests.UpdateCalculationStrategyRequest request) {
InboundOrderEntity inbound = repository.lockInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
requireMeasurementRevisionEditable(tenantId, inboundId, inbound, "箱规计算规则");
if ("MULTIPLY".equals(request.coefficientMode()) && request.chargeRuleId() == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "箱规系数模式必须选择计费规则");
}
InboundMeasurementEntity measurement = repository.lockMeasurement(
tenantId, inboundId, request.measurementId());
if (measurement == null) throw new BizException(ResultCode.NOT_FOUND, "测量批次不存在");
requireVersion(request.version(), measurement.getVersion());
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
batchService.recalculateStrategy(tenantId, measurement, items,
request.coefficientMode(), request.chargeRuleId());
measurement.setUpdatedAt(LocalDateTime.now());
if (!repository.updateMeasurement(measurement)) throw concurrentModification();
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
if (!repository.updateInbound(inbound)) throw concurrentModification();
Long appliedRuleId = "MULTIPLY".equals(request.coefficientMode()) ? request.chargeRuleId() : null;
return new InboundMeasurementResponses.CalculationStrategyUpdated(
measurement.getId(), measurement.getVersion(), measurement.getStatus(),
request.coefficientMode(), appliedRuleId);
}
@Transactional
public InboundMeasurementResponses.OuterDimensionsUpdated updateOuterDimensions(
long tenantId, long inboundId, long itemId,
InboundMeasurementRequests.UpdateOuterDimensionsRequest request) {
InboundOrderEntity inbound = repository.lockInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
requireMeasurementRevisionEditable(tenantId, inboundId, inbound, "仓库外箱尺寸");
InboundMeasurementEntity measurement = repository.lockMeasurement(
tenantId, inboundId, request.measurementId());
if (measurement == null) throw new BizException(ResultCode.NOT_FOUND, "测量批次不存在");
requireVersion(request.version(), measurement.getVersion());
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
batchService.recalculateOuterDimensions(tenantId, measurement, items, itemId,
request.outerLengthCm(), request.outerWidthCm(), request.outerHeightCm());
measurement.setUpdatedAt(LocalDateTime.now());
if (!repository.updateMeasurement(measurement)) throw concurrentModification();
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
if (!repository.updateInbound(inbound)) throw concurrentModification();
return new InboundMeasurementResponses.OuterDimensionsUpdated(
measurement.getId(), measurement.getVersion(), measurement.getStatus(), itemId);
}
private void requireMeasurementRevisionEditable(long tenantId, long inboundId,
InboundOrderEntity inbound, String subject) {
var currentFee = pricingRepository.findLatestFeeForUpdate(tenantId, inboundId);
boolean customerConfirmed = currentFee != null && currentFee.getCustomerConfirmedAt() != null;
boolean loadingCompleted = Set.of("LOADED", "DELIVERED").contains(inbound.getProcessStatus());
if (customerConfirmed && loadingCompleted) {
throw new BizException(ResultCode.CONFLICT,
"客户已确认且该票已装柜完成,不能修改" + subject);
}
}
@Transactional
public InboundMeasurementResponses.MeasurementSubmitted submit(
long tenantId, long inboundId, long operatorId,
@@ -180,6 +245,10 @@ public class InboundMeasurementService {
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
ConfirmedWeightTotals confirmedTotals = confirmedWeightTotals(weights);
if (confirmedTotals.palletCount() == 0) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"请至少上传并确认一张称重图片后再提交测量审核");
}
requireConfirmedPackageTotal(inbound, items);
measurement.setStatus(STATUS_SUBMITTED);
@@ -246,7 +246,7 @@ public class LoadingPickService {
public InventorySnapshot inventorySnapshot(long taskId, long warehouseId) {
LoadingPickTaskEntity task = requiredTask(taskId);
List<LoadingPickItemEntity> items = warehouseScopedItems(task, warehouseId);
List<LoadingPickItemEntity> items = taskItems(task, warehouseId);
return buildInventorySnapshot(task, warehouseId, items);
}
@@ -326,10 +326,7 @@ public class LoadingPickService {
assertReplay(replay, taskId, itemId, requestHash);
return view(task, request.warehouseId());
}
LoadingPickItemEntity item = repository.item(taskId, itemId, request.warehouseId());
if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在");
}
LoadingPickItemEntity item = requireTaskItem(taskId, itemId, request.warehouseId());
requirePackageUnit(item);
if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) {
throw conflict("拣货数量超过计划箱数或明细版本已变化");
@@ -404,7 +401,7 @@ public class LoadingPickService {
assertReplay(replay, taskId, itemId, requestHash);
return view(task, request.warehouseId());
}
LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId());
LoadingPickItemEntity item = requireTaskItem(taskId, itemId, request.warehouseId());
requirePackageUnit(item);
if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(),
request.warehouseId()) != 1) {
@@ -437,7 +434,7 @@ public class LoadingPickService {
assertReplay(replay, taskId, itemId, requestHash);
return view(task, request.warehouseId());
}
LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId());
LoadingPickItemEntity item = requireTaskItem(taskId, itemId, request.warehouseId());
requirePackageUnit(item);
if (repository.hasPickedOrPartialItem(taskId, item.getInboundId())) {
throw conflict("同一入库单已存在已拣或部分拣货明细,不能跳拣并恢复智能装柜候选");
@@ -940,10 +937,10 @@ public class LoadingPickService {
return task;
}
private LoadingPickItemEntity requireWarehouseItem(long taskId, long itemId, long warehouseId) {
private LoadingPickItemEntity requireTaskItem(long taskId, long itemId, long warehouseId) {
LoadingPickItemEntity item = repository.item(taskId, itemId, warehouseId);
if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在或不属于当前仓库");
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在或不属于当前任务");
}
return item;
}
@@ -953,16 +950,12 @@ public class LoadingPickService {
if (booking == null || !Objects.equals(task.getContainerBookingId(), booking.getId())) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或柜次已非有效存续记录");
}
if (warehouseId != null && !Objects.equals(warehouseId, booking.getWarehouseId())) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或不属于当前仓库");
}
List<LoadingPickItemEntity> source = warehouseId == null
? repository.items(task.getId()) : repository.items(task.getId(), warehouseId);
Map<Long, List<CartonLabelRow>> cartonsByItem = repository.cartonLabels(task.getId()).stream()
.collect(Collectors.groupingBy(CartonLabelRow::getPickItemId));
Map<Long, Long> tenantByInbound = tenantByInbound(task.getId());
Map<Long, List<InventoryResource>> inventoryByInbound = inventoryByInbound(
booking.getWarehouseId(), source, tenantByInbound);
Map<Long, List<InventoryResource>> inventoryByInbound = inventoryByInbound(source, tenantByInbound);
List<PickItem> items = source.stream().map(item -> {
List<CartonLabelRow> cartons = cartonsByItem.getOrDefault(item.getId(), List.of());
CartonLabelRow first = cartons.isEmpty() ? null : cartons.get(0);
@@ -1011,11 +1004,11 @@ public class LoadingPickService {
private List<BatchLine> validateBatchLines(LoadingPickTaskEntity task, BatchRequest request) {
long taskId = task.getId();
List<LoadingPickItemEntity> allItems = repository.items(taskId);
List<LoadingPickItemEntity> scopedItems = repository.items(taskId, request.warehouseId());
if (allItems.isEmpty() || scopedItems.size() != allItems.size()) {
throw conflict("PDA任务柜次、仓库或客户归属不一致");
List<LoadingPickItemEntity> taskItems = repository.items(taskId, request.warehouseId());
if (allItems.isEmpty() || taskItems.size() != allItems.size()) {
throw conflict("PDA任务柜次或客户归属不一致");
}
Map<Long, LoadingPickItemEntity> itemsById = scopedItems.stream().collect(Collectors.toMap(
Map<Long, LoadingPickItemEntity> itemsById = taskItems.stream().collect(Collectors.toMap(
LoadingPickItemEntity::getId, item -> item));
Set<Long> unresolvedIds = allItems.stream().filter(item -> !isResolved(item))
.map(LoadingPickItemEntity::getId).collect(Collectors.toSet());
@@ -1033,7 +1026,7 @@ public class LoadingPickService {
LoadingPickItemEntity item = itemsById.get(requestItem.itemId());
if (item == null || item.getInboundId() == null || item.getLoadingPlanItemId() == null
|| item.getInboundDetailId() == null) {
throw conflict("PDA明细不属于当前任务、柜次、仓库或客户");
throw conflict("PDA明细不属于当前任务、柜次或客户");
}
if (!"SKIP".equals(requestItem.action())) {
inboundHasWork.put(item.getInboundId(), true);
@@ -1096,7 +1089,7 @@ public class LoadingPickService {
}
result.add(new BatchLine(item, requestItem, action, request.warehouseId()));
}
validateWholePallets(result, scopedItems, request.warehouseId());
validateWholePallets(result, taskItems, request.warehouseId());
result.sort(Comparator.comparing(line -> line.item().getId()));
return result;
}
@@ -1121,19 +1114,19 @@ public class LoadingPickService {
}
}
private List<LoadingPickItemEntity> warehouseScopedItems(LoadingPickTaskEntity task, long warehouseId) {
private List<LoadingPickItemEntity> taskItems(LoadingPickTaskEntity task, long warehouseId) {
List<LoadingPickItemEntity> allItems = repository.items(task.getId());
List<LoadingPickItemEntity> scopedItems = repository.items(task.getId(), warehouseId);
if (allItems.isEmpty() || scopedItems.size() != allItems.size()) {
throw conflict("PDA任务柜次、仓库或客户归属不一致");
List<LoadingPickItemEntity> taskItems = repository.items(task.getId(), warehouseId);
if (allItems.isEmpty() || taskItems.size() != allItems.size()) {
throw conflict("PDA任务柜次或客户归属不一致");
}
return scopedItems;
return taskItems;
}
private InventorySnapshot buildInventorySnapshot(LoadingPickTaskEntity task, long warehouseId,
List<LoadingPickItemEntity> items) {
Map<Long, List<InventoryResource>> inventoryByInbound = inventoryByInbound(
warehouseId, items, tenantByInbound(task.getId()));
items, tenantByInbound(task.getId()));
List<InboundInventory> inbounds = items.stream().map(LoadingPickItemEntity::getInboundId)
.filter(Objects::nonNull).distinct().sorted().map(inboundId -> {
List<InventoryResource> resources = inventoryByInbound.getOrDefault(inboundId, List.of());
@@ -1160,8 +1153,7 @@ public class LoadingPickService {
}));
}
private Map<Long, List<InventoryResource>> inventoryByInbound(long warehouseId,
List<LoadingPickItemEntity> items,
private Map<Long, List<InventoryResource>> inventoryByInbound(List<LoadingPickItemEntity> items,
Map<Long, Long> tenantByInbound) {
Map<Long, List<InventoryResource>> result = new HashMap<>();
for (Long inboundId : items.stream().map(LoadingPickItemEntity::getInboundId)
@@ -1169,7 +1161,6 @@ public class LoadingPickService {
Long tenantId = tenantByInbound.get(inboundId);
if (tenantId == null) throw conflict("PDA任务缺少入库单客户归属");
List<InventoryResource> resources = palletAllocations.list(tenantId, inboundId).stream()
.filter(resource -> Objects.equals(resource.getWarehouseId(), warehouseId))
.filter(resource -> "CONFIRMED".equals(resource.getAllocationStatus()))
.sorted(Comparator.comparing(InboundPalletAllocationEntity::getBlockId,
Comparator.nullsLast(Long::compareTo))
@@ -1222,7 +1213,7 @@ public class LoadingPickService {
private void validateInventorySnapshot(LoadingPickTaskEntity task, BatchRequest request,
List<BatchLine> lines) {
List<LoadingPickItemEntity> allItems = warehouseScopedItems(task, request.warehouseId());
List<LoadingPickItemEntity> allItems = taskItems(task, request.warehouseId());
InventorySnapshot current = buildInventorySnapshot(task, request.warehouseId(), allItems);
if (!current.snapshotToken().equals(request.inventorySnapshotToken().trim())) {
throw conflict("库存已变化,请刷新后重试");
@@ -93,6 +93,87 @@ public class MeasurementBatchService {
return measurement;
}
void recalculateStrategy(long tenantId, InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> existingItems,
String coefficientMode, Long chargeRuleId) {
if (existingItems.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "当前测量批次没有可重算的明细");
}
MeasurementBatchCommand command = new MeasurementBatchCommand(
tenantId, measurement.getInboundId(), measurement.getId(), measurement.getVersion(),
measurement.getSourceType(), measurement.getSourceCode(), measurement.getExternalEventNo(),
measurement.getDeviceCode(), measurement.getWarehouseCode(), measurement.getMeasuredAt(),
measurement.getRawPayloadJson(), measurement.getOriginalDocumentNo(),
measurement.getOriginalGrossWeightKg(), measurement.getOriginalVolumeCbm(),
measurement.getActualGoodsName(), measurement.getCreatedBy(),
existingItems.stream().map(item -> new MeasurementBatchCommand.Item(
item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), null, item.getSourceImageFileId(),
item.getOverLimitWarning(), item.getManualOuterLengthCm(), item.getManualOuterWidthCm(),
item.getManualOuterHeightCm(), chargeRuleId, coefficientMode, item.getRemark())).toList());
List<InboundMeasurementItemEntity> recalculated = calculateItems(
command, dimensionWeighting.load(tenantId));
for (int index = 0; index < existingItems.size(); index++) {
InboundMeasurementItemEntity target = existingItems.get(index);
InboundMeasurementItemEntity source = recalculated.get(index);
applyRecalculatedItem(target, source, false);
}
applyTotals(measurement, existingItems);
}
void recalculateOuterDimensions(long tenantId, InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> existingItems, long itemId,
BigDecimal outerLengthCm, BigDecimal outerWidthCm,
BigDecimal outerHeightCm) {
InboundMeasurementItemEntity target = existingItems.stream()
.filter(item -> item.getId() != null && item.getId() == itemId)
.findFirst()
.orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "测量明细不存在"));
MeasurementBatchCommand command = new MeasurementBatchCommand(
tenantId, measurement.getInboundId(), measurement.getId(), measurement.getVersion(),
measurement.getSourceType(), measurement.getSourceCode(), measurement.getExternalEventNo(),
measurement.getDeviceCode(), measurement.getWarehouseCode(), measurement.getMeasuredAt(),
measurement.getRawPayloadJson(), measurement.getOriginalDocumentNo(),
measurement.getOriginalGrossWeightKg(), measurement.getOriginalVolumeCbm(),
measurement.getActualGoodsName(), measurement.getCreatedBy(),
List.of(new MeasurementBatchCommand.Item(
target.getInboundDetailId(), target.getItemNo(), target.getBoxSpecification(),
target.getPackageCount(), target.getLengthCm(), target.getWidthCm(), target.getHeightCm(),
null, target.getSourceImageFileId(), target.getOverLimitWarning(), outerLengthCm,
outerWidthCm, outerHeightCm, target.getChargeRuleId(), target.getCoefficientMode(),
target.getRemark())));
InboundMeasurementItemEntity recalculated = calculateItems(
command, dimensionWeighting.load(tenantId)).get(0);
applyRecalculatedItem(target, recalculated, true);
applyTotals(measurement, existingItems);
}
private void applyRecalculatedItem(InboundMeasurementItemEntity target,
InboundMeasurementItemEntity source,
boolean updateManualDimensions) {
target.setGirthCm(source.getGirthCm());
target.setUnitVolumeCbm(source.getUnitVolumeCbm());
target.setVolumeRawCbm(source.getVolumeRawCbm());
target.setVolumeCbm(source.getVolumeCbm());
target.setTotalVolumeCbm(source.getTotalVolumeCbm());
target.setVolumeWeightKg(source.getVolumeWeightKg());
target.setChargeRuleId(source.getChargeRuleId());
target.setCoefficientMode(source.getCoefficientMode());
target.setCalculationRuleVersion(source.getCalculationRuleVersion());
target.setOuterLengthCm(source.getOuterLengthCm());
target.setOuterWidthCm(source.getOuterWidthCm());
target.setOuterHeightCm(source.getOuterHeightCm());
if (updateManualDimensions) {
target.setManualOuterLengthCm(source.getManualOuterLengthCm());
target.setManualOuterWidthCm(source.getManualOuterWidthCm());
target.setManualOuterHeightCm(source.getManualOuterHeightCm());
}
target.setUpdatedAt(LocalDateTime.now());
if (!repository.updateItem(target)) {
throw concurrentModification();
}
}
/**
* 人工补录会整批替换可编辑测量项,但不能抹掉同一项已冻结的扫码身份与设备取证。
* 只按入库货物明细和明细号精确匹配,避免把一个箱码猜测性地复制给其他测量项。
@@ -18,7 +18,11 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId,
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
FROM fl_container_booking b
WHERE b.id=#{containerBookingId} FOR UPDATE
WHERE b.id=#{containerBookingId}
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d
WHERE d.container_booking_id=b.id AND d.confirmed_at IS NOT NULL
AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
FOR UPDATE
""")
Context lockContext(@Param("containerBookingId") long containerBookingId);
@@ -27,6 +31,9 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
FROM fl_container_booking b
WHERE b.id=#{containerBookingId}
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d
WHERE d.container_booking_id=b.id AND d.confirmed_at IS NOT NULL
AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
""")
Context context(@Param("containerBookingId") long containerBookingId);
@@ -37,6 +44,9 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
FROM fl_container_booking b
WHERE b.id IN
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d
WHERE d.container_booking_id=b.id AND d.confirmed_at IS NOT NULL
AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
ORDER BY b.id
</script>
""")
@@ -45,8 +45,10 @@ public class ContainerWorkbenchService {
public WorkbenchItem overseasDetail(long id, Set<String> permissions) {
ContainerBookingEntity value = required(id);
if (!repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国外工作台货柜不存在");
View drayage = terminalDrayages.authorize(terminalDrayages.detail(id), permissions);
return item("OVERSEAS", value, drayage.actualFacts(), drayage, permissions);
View drayage = terminalDrayages.authorize(terminalDrayages.views(List.of(id)), permissions).stream()
.filter(candidate -> candidate.containerBookingId() == id)
.findFirst().orElse(null);
return item("OVERSEAS", value, drayage == null ? null : drayage.actualFacts(), drayage, permissions);
}
private PageResult<WorkbenchItem> page(String workbench, Query query, Set<String> permissions) {
@@ -5,7 +5,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest;
@@ -123,7 +123,7 @@ public class TerminalDrayageService {
requireVersion(value, request.expectedVersion());
if (value.getFleetSupplierId() == null) throw conflict("请先选择有效码头车队");
if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可修改预约提柜时间");
if (context.facts().arrivedDestinationPortAt() != null
if (context.facts() != null && context.facts().arrivedDestinationPortAt() != null
&& request.pickupAppointmentAt().isBefore(context.facts().arrivedDestinationPortAt())) {
throw badRequest("预约提柜时间不能早于实际到港时间");
}
@@ -149,7 +149,8 @@ public class TerminalDrayageService {
throw conflict("选择码头车队并预约提柜后才能确认实际提柜");
}
if (actualPickupAt(context, value) != null) throw conflict("实际提柜时间已确认");
if (context.facts().latestActualAt() != null && request.actualAt().isBefore(context.facts().latestActualAt())) {
if (context.facts() != null && context.facts().latestActualAt() != null
&& request.actualAt().isBefore(context.facts().latestActualAt())) {
throw badRequest("实际提柜时间不能早于当前实际船节点时间");
}
advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key,
@@ -192,14 +193,11 @@ public class TerminalDrayageService {
private void advanceNode(WorkContext context, CarrierScheduleNode target, LocalDateTime actualAt,
String action, String key, String reason, long operatorId, String operatorName) {
if (context.facts().queryId() == null || context.facts().queryVersion() == null) {
throw conflict("货柜尚未建立船节点跟踪任务");
}
CarrierScheduleNode current = node(context.facts().latestActualNode());
CarrierScheduleNode current = currentNode(context);
if (current != null && current.sequence() >= target.sequence()) return;
carrierSchedules.addManualNode(context.facts().queryId(), operatorId, operatorName,
carrierSchedules.addContainerManualNode(context.base().containerBookingId(), operatorId, operatorName,
"td-" + hash(context.base().containerBookingId() + "|" + action + "|" + key.trim()),
new ManualNodeRequest(context.facts().queryVersion(), target.name(), LocalDateTime.now(clock), null,
new ContainerManualNodeRequest(target.name(), LocalDateTime.now(clock), null,
actualAt, null, null, StringUtils.hasText(reason) ? reason.trim() : "码头拖车操作同步"));
}
@@ -292,7 +290,7 @@ public class TerminalDrayageService {
List<String> result = new ArrayList<>();
if (value != null && value.getFleetSupplierId() != null && actualPickupAt(context, value) == null
&& actualEmptyReturnAt(context, value) == null && context.base().unloadingCompletedAt() == null) result.add("SEND_PICKUP_EMAIL");
CarrierScheduleNode current = node(context.facts().latestActualNode());
CarrierScheduleNode current = currentNode(context);
if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
&& current.sequence() < CarrierScheduleNode.PICKED_UP.sequence()
&& actualPickupAt(context, value) == null) {
@@ -312,7 +310,7 @@ public class TerminalDrayageService {
if (context.base().unloadingCompletedAt() != null) return "UNLOADED";
if (actualPickupAt(context, value) != null) return "PICKED_UP";
if (value != null && value.getPickupAppointmentAt() != null) return "PICKUP_SCHEDULED";
CarrierScheduleNode current = node(context.facts().latestActualNode());
CarrierScheduleNode current = currentNode(context);
return current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
? "ARRIVED" : "WAITING_FOR_ARRIVAL";
}
@@ -322,7 +320,7 @@ public class TerminalDrayageService {
}
private void requireAtLeast(WorkContext context, CarrierScheduleNode required, String message) {
CarrierScheduleNode current = node(context.facts().latestActualNode());
CarrierScheduleNode current = currentNode(context);
if (current == null || current.sequence() < required.sequence()) throw conflict(message);
}
@@ -330,13 +328,18 @@ public class TerminalDrayageService {
return StringUtils.hasText(value) ? CarrierScheduleNode.require(value) : null;
}
private CarrierScheduleNode currentNode(WorkContext context) {
return context.facts() == null ? null : node(context.facts().latestActualNode());
}
private LocalDateTime actualPickupAt(WorkContext context, TerminalDrayageEntity value) {
return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt() : context.facts().pickedUpAt();
return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt()
: context.facts() == null ? null : context.facts().pickedUpAt();
}
private LocalDateTime actualEmptyReturnAt(WorkContext context, TerminalDrayageEntity value) {
return value != null && value.getActualEmptyReturnAt() != null
? value.getActualEmptyReturnAt() : context.facts().emptyReturnedAt();
? value.getActualEmptyReturnAt() : context.facts() == null ? null : context.facts().emptyReturnedAt();
}
private WorkContext requiredContext(long id) {
@@ -362,8 +365,7 @@ public class TerminalDrayageService {
}
private WorkContext context(Context base, ContainerActualFacts facts) {
if (base == null || !"ACTIVE".equals(base.recordStatus()) || facts == null
|| facts.departedWarehouseAt() == null) return null;
if (base == null || !"ACTIVE".equals(base.recordStatus())) return null;
return new WorkContext(base, facts);
}
@@ -24,6 +24,7 @@ CALL phase2_fba_add_column('fl_customer_charge_rule', 'length_factor', "DECIMAL(
CALL phase2_fba_add_column('fl_customer_charge_rule', 'width_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '宽度计算系数' AFTER length_factor");
CALL phase2_fba_add_column('fl_customer_charge_rule', 'height_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '高度计算系数' AFTER width_factor");
CALL phase2_fba_add_column('fl_customer_charge_rule', 'default_rule', "TINYINT(1) NOT NULL DEFAULT 0 COMMENT '客户默认生效规则' AFTER status");
CALL phase2_fba_add_column('fl_customer_charge_rule', 'initialization_key', "VARCHAR(128) NULL COMMENT '客户创建执行器幂等键及补偿归属' AFTER remark");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted");
CALL phase2_fba_add_column('fl_inbound_fee', 'rule_code', "VARCHAR(50) NOT NULL DEFAULT '' COMMENT '客户规则编码快照' AFTER carrier_rate_version");
CALL phase2_fba_add_column('fl_inbound_fee', 'measurement_id', "BIGINT UNSIGNED NULL COMMENT '费用绑定的测量批次ID快照' AFTER inbound_id");
@@ -67,6 +68,14 @@ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
DROP PROCEDURE IF EXISTS phase2_fba_add_column;
SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_customer_charge_rule'
AND INDEX_NAME = 'idx_charge_rule_initialization');
SET @sql = IF(@idx_exists = 0,
'ALTER TABLE fl_customer_charge_rule ADD INDEX idx_charge_rule_initialization (tenant_id, initialization_key)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 旧索引可能缺少业务类型、船司和结束时间;用新索引名并存,避免破坏线上依赖。
SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_carrier_fba_rate' AND INDEX_NAME = 'idx_fba_rate_unique_match');
@@ -431,6 +431,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule (
effective_to DATETIME(3) NULL COMMENT '生效结束时间',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态:ACTIVE启用、DISABLED停用',
remark VARCHAR(500) NULL COMMENT '规则备注',
initialization_key VARCHAR(128) NULL COMMENT '客户创建执行器幂等键及补偿归属',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
@@ -439,6 +440,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule (
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_charge_rule_version (tenant_id, rule_code, rule_version),
KEY idx_charge_rule_initialization (tenant_id, initialization_key),
KEY idx_charge_rule_match (tenant_id, customer_id, business_type, status, effective_from, effective_to)
) ENGINE=InnoDB COMMENT='客户头程计费规则版本';
@@ -52,7 +52,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
CONSTRAINT fk_carrier_schedule_query_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_carrier_schedule_query_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT fk_carrier_schedule_query_result FOREIGN KEY (result_port_schedule_id) REFERENCES fl_port_schedule(id),
CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id > 0),
CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id >= 0),
CONSTRAINT chk_carrier_schedule_query_carrier CHECK
(carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')),
CONSTRAINT chk_carrier_schedule_query_tracking CHECK
@@ -114,7 +114,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history (
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
id BIGINT UNSIGNED NOT NULL COMMENT '标准船节点事实ID',
query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID',
query_id BIGINT UNSIGNED NULL COMMENT '自动跟踪任务ID;人工补录时为空',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属真实货柜ID',
event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键',
provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码',
provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID',
@@ -130,9 +131,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_node_event (query_id,event_key),
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id),
KEY idx_container_shipping_node_timeline (container_booking_id,observed_at,id),
CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
CONSTRAINT fk_carrier_schedule_node_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_carrier_schedule_node_standard CHECK
(standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT',
'IN_TRANSIT','ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')),
@@ -34,7 +34,7 @@ paths:
summary: Query carrier schedule tasks
x-permission: platform:firstleg:port-schedule-query:list
parameters:
- {name: customerId, in: query, schema: {type: integer, format: int64, minimum: 1}}
- {name: customerId, in: query, schema: {type: integer, format: int64, minimum: 0}}
- {name: oceanBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}}
- {name: containerBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}}
- {name: carrierCode, in: query, schema: {$ref: '#/components/schemas/TaskCarrierCode'}}
@@ -126,7 +126,7 @@ components:
required: [expectedVersion, customerId, oceanBookingId, containerBookingId, carrierCode, trackingType, trackingNo]
properties:
expectedVersion: {type: integer, minimum: 0, maximum: 0}
customerId: {type: integer, format: int64, minimum: 1}
customerId: {type: integer, format: int64, minimum: 0}
oceanBookingId: {type: integer, format: int64, minimum: 1}
containerBookingId: {type: integer, format: int64, minimum: 1}
carrierCode: {$ref: '#/components/schemas/CarrierCode'}
@@ -123,13 +123,13 @@ components:
schemas:
LongId: {type: string, pattern: '^[1-9][0-9]*$', description: Long ID序列化为字符串}
ApiEnvelope: {type: object, required: [code, message, timestamp], properties: {code: {type: string}, message: {type: string}, requestId: {type: string, nullable: true}, traceId: {type: string, nullable: true}, timestamp: {type: string, format: date-time}}}
ContainerEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/ContainerView'}}}]}
ContainerEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {allOf: [{$ref: '#/components/schemas/ContainerView'}], nullable: true, description: 装柜计划尚未完成时返回null,表示报关流程尚未生成}}}]}
DocumentEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DocumentView'}}}]}
ActionRequest: {type: object, required: [expectedVersion, confirm], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, confirm: {type: boolean}, remark: {type: string, maxLength: 500, nullable: true}}}
ScopeRequest: {type: object, required: [inboundIds, expectedVersion], additionalProperties: false, properties: {inboundIds: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}, expectedVersion: {type: integer, minimum: 1}}}
FileView: {type: object, required: [fileId, fileName, fileHash, sizeBytes, version, uploadedAt], properties: {fileId: {$ref: '#/components/schemas/LongId'}, fileName: {type: string}, fileHash: {type: string}, contentType: {type: string, nullable: true}, sizeBytes: {type: integer, format: int64}, version: {type: integer}, uploadedAt: {type: string, format: date-time}}}
InboundView: {type: object, required: [inboundId, orderId, orderNo, inboundCode, tenantId, customerName, taxMode, covered], properties: {inboundId: {$ref: '#/components/schemas/LongId'}, orderId: {$ref: '#/components/schemas/LongId'}, orderNo: {type: string}, inboundCode: {type: string}, tenantId: {$ref: '#/components/schemas/LongId'}, customerName: {type: string}, taxMode: {type: string, enum: [TAX_REFUND, BUY_DECLARATION]}, covered: {type: boolean}}}
DocumentView: {type: object, required: [documentId, tenantId, source, inboundIds, inboundCodes, frozen], properties: {documentId: {$ref: '#/components/schemas/LongId'}, tenantId: {$ref: '#/components/schemas/LongId'}, source: {$ref: '#/components/schemas/FileView'}, inboundIds: {type: array, items: {$ref: '#/components/schemas/LongId'}}, inboundCodes: {type: array, items: {type: string}}, frozen: {type: boolean}, completion: {allOf: [{$ref: '#/components/schemas/FileView'}], nullable: true}}}
ContainerView: {type: object, required: [aggregateId, loadingPlanId, containerBookingId, status, version, taxRefundCount, coveredTaxRefundCount, inbounds, documents], properties: {aggregateId: {$ref: '#/components/schemas/LongId'}, loadingPlanId: {$ref: '#/components/schemas/LongId'}, containerBookingId: {$ref: '#/components/schemas/LongId'}, planNo: {type: string}, cabinetNo: {type: string}, containerNo: {type: string, nullable: true}, status: {type: string, enum: [WAITING_CUSTOMER_DOCUMENTS, READY_TO_START, DECLARING, COMPLETED]}, merchantStatus: {type: string, enum: [WAITING_UPLOAD, DOCUMENT_UPLOADED, DECLARING, CUSTOMS_COMPLETED], nullable: true}, version: {type: integer}, taxRefundCount: {type: integer}, coveredTaxRefundCount: {type: integer}, inbounds: {type: array, items: {$ref: '#/components/schemas/InboundView'}}, documents: {type: array, items: {$ref: '#/components/schemas/DocumentView'}}, startedAt: {type: string, format: date-time, nullable: true}, completedAt: {type: string, format: date-time, nullable: true}}}
ContainerView: {type: object, required: [aggregateId, loadingPlanId, containerBookingId, status, hasActualCargo, version, taxRefundCount, coveredTaxRefundCount, inbounds, documents], properties: {aggregateId: {$ref: '#/components/schemas/LongId'}, loadingPlanId: {$ref: '#/components/schemas/LongId'}, containerBookingId: {$ref: '#/components/schemas/LongId'}, planNo: {type: string}, cabinetNo: {type: string}, containerNo: {type: string, nullable: true}, status: {type: string, enum: [WAITING_CUSTOMER_DOCUMENTS, READY_TO_START, DECLARING, COMPLETED]}, hasActualCargo: {type: boolean, description: 当前完成装柜计划是否仍有实际装柜货物;false时查询成功返回空明细且无需发起报关}, merchantStatus: {type: string, enum: [WAITING_UPLOAD, DOCUMENT_UPLOADED, DECLARING, CUSTOMS_COMPLETED], nullable: true}, version: {type: integer}, taxRefundCount: {type: integer}, coveredTaxRefundCount: {type: integer}, inbounds: {type: array, items: {$ref: '#/components/schemas/InboundView'}}, documents: {type: array, items: {$ref: '#/components/schemas/DocumentView'}}, startedAt: {type: string, format: date-time, nullable: true}, completedAt: {type: string, format: date-time, nullable: true}}}
ExportBatchRequest: {type: object, required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit], properties: {taskId: {type: string}, tenantId: {type: string, enum: ['0']}, appCode: {type: string, enum: [PLATFORM]}, requestedBy: {type: string}, dispatchVersion: {type: integer}, parameters: {type: object, required: [loadingPlanId], additionalProperties: false, properties: {loadingPlanId: {type: string}}}, cursor: {type: string, nullable: true}, snapshotToken: {type: string, nullable: true}, limit: {type: integer, minimum: 1, maximum: 500}}}
ExportBatchEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {type: object, properties: {schemaVersion: {type: integer}, schemaHash: {type: string}, snapshotToken: {type: string}, columns: {type: array, items: {type: object}}, rows: {type: array, items: {type: object}}, nextCursor: {type: string}, hasMore: {type: boolean}, totalRows: {type: integer, format: int64, nullable: true}}}}}]}
@@ -923,6 +923,39 @@ paths:
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
'403': {description: 无查看权限}
/api/platform/inbounds/carton-label-pdf:
post:
summary: 批量生成入仓箱唛 PDF
description: 需要 platform:firstleg:inbound:view 权限;可跨客户选择1至200个入库单,聚合生成一个 PDF,首次调用冻结各入库单整单箱号。
requestBody:
required: true
content:
application/json:
schema:
type: array
minItems: 1
maxItems: 200
items:
type: object
required: [tenantId, inboundId]
properties:
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
responses:
'200':
description: 成功返回聚合 PDF;业务失败返回统一 JSON 响应,调用方必须按 Content-Type 区分。
content:
application/pdf: {schema: {type: string, format: binary}}
application/json:
schema:
type: object
required: [code, message]
properties:
code: {type: string}
message: {type: string}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
'403': {description: 无查看权限}
/api/platform/v1/first-leg/inbounds/{inboundId}/measurement:
get:
summary: 查询入库测量详情
@@ -964,6 +997,43 @@ paths:
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 无测量编辑权限}
/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/calculation-strategy:
put:
summary: 更新箱规计算规则
description: >-
独立更新当前测量批次全部明细的箱规计算规则并重算体积,不覆盖整批测量数据,
不改变测量提交或审核状态。客户未确认或该票尚未装柜完成时均可修改;
仅当客户已确认费用且该票已装柜完成时冻结。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/InboundMeasurementCalculationStrategyRequest'}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 无测量编辑权限}
/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/items/{itemId}/outer-dimensions:
put:
summary: 更新仓库外箱长宽高
description: >-
独立更新当前测量批次指定明细的仓库外箱尺寸并重算体积,不覆盖整批测量数据,
不改变测量提交或审核状态。客户未确认或该票尚未装柜完成时均可修改;
仅当客户已确认费用且该票已装柜完成时冻结。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: itemId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/InboundMeasurementOuterDimensionsRequest'}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 无测量编辑权限}
/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans:
post:
summary: 平台模拟单箱设备扫描
@@ -1681,16 +1751,18 @@ components:
updatedAt: {type: string, format: date-time, nullable: true}
CustomerDimensionConfigInitializeRequest:
type: object
required: [tenantId, operatorId, initializationKey]
required: [tenantId, operatorId, initializationKey, customerName]
properties:
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
operatorId: {type: string, pattern: '^[0-9]+$'}
initializationKey: {type: string, minLength: 1, maxLength: 128}
customerName: {type: string, minLength: 1, maxLength: 128}
CustomerDimensionConfigInitializeResponse:
type: object
required: [configId, createdByThisInitialization]
required: [configId, chargeRuleId, createdByThisInitialization]
properties:
configId: {type: string, pattern: '^[1-9][0-9]*$'}
chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$'}
createdByThisInitialization: {type: boolean}
WeightPalletSnapshot:
type: object
@@ -1933,6 +2005,25 @@ components:
minItems: 1
maxItems: 500
items: {$ref: '#/components/schemas/InboundMeasurementItemRequest'}
InboundMeasurementCalculationStrategyRequest:
type: object
required: [measurementId, version, coefficientMode]
additionalProperties: false
properties:
measurementId: {type: string, pattern: '^[1-9][0-9]*$'}
version: {type: integer, minimum: 0}
coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY]}
chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: MULTIPLY 模式必填}
InboundMeasurementOuterDimensionsRequest:
type: object
required: [measurementId, version]
additionalProperties: false
properties:
measurementId: {type: string, pattern: '^[1-9][0-9]*$'}
version: {type: integer, minimum: 0}
outerLengthCm: {type: number, minimum: 0.001, nullable: true}
outerWidthCm: {type: number, minimum: 0.001, nullable: true}
outerHeightCm: {type: number, minimum: 0.001, nullable: true}
InboundMeasurementItemRequest:
type: object
required: [inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm]
@@ -27,7 +27,7 @@ paths:
/api/platform/v1/containers/{id}/pda/photos/{fileId}/download:
get:
operationId: downloadContainerPdaPhoto
description: '权限:platform:firstleg:container:list;Firstleg按柜次、装柜计划、入库单和文件归属代理下载,不暴露内部服务令牌'
description: '权限:platform:firstleg:container:list;PDA完成及国内离仓后仍可读取ACTIVE历史照片;Firstleg按柜次、装柜计划、入库单和文件归属代理下载,不暴露内部服务令牌'
parameters: [{$ref: '#/components/parameters/ContainerId'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}]
responses: {'200': {description: photo bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}, '404': {description: photo missing or not owned by the container}}
/api/platform/v1/containers/{id}/documents/{documentType}:
@@ -14,6 +14,7 @@ paths:
/api/platform/v1/terminal-drayages/{containerBookingId}:
get:
summary: 查询单柜码头拖车详情
description: 已确认国内离仓的货柜即使船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情;实际操作仍按到港、清关和卸柜节点校验
x-permission: platform:firstleg:terminal-drayage:list
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
@@ -63,6 +64,7 @@ paths:
/api/platform/v1/terminal-drayages/{containerBookingId}/emails:
get:
summary: 查询最近100封车队邮件及发信开关
description: 已确认国内离仓的货柜允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件
x-permission: platform:firstleg:terminal-drayage:list
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
responses:
@@ -1,20 +1,52 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.service.ContainerOverviewService;
import com.baiyue.firstleg.service.ContainerService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.core.io.ClassPathResource;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@DisplayName("货柜基础资料接口契约")
class ContainerControllerContractTest {
@Test
@DisplayName("PDA照片作为历史证据下载时不受国内工作台分区限制")
void downloadsPdaPhotoWithoutDomesticWorkbenchGate() {
ContainerService containers = mock(ContainerService.class);
ContainerOverviewService overview = mock(ContainerOverviewService.class);
PlatformContainerController controller = new PlatformContainerController();
ReflectionTestUtils.setField(controller, "service", containers);
ReflectionTestUtils.setField(controller, "overview", overview);
when(overview.downloadPhoto(41L, 101L)).thenReturn(new ContainerOverviewService.PhotoDownload(
"door.jpg", MediaType.IMAGE_JPEG, new byte[]{1, 2, 3}));
AuthenticatedUser user = new AuthenticatedUser(7L, 0L, 0L, 8L,
"admin", "PLATFORM", "WEB", Set.of("platform:firstleg:container:list"));
var response = controller.downloadPdaPhoto(user, 41L, 101L);
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.IMAGE_JPEG);
assertThat(response.getBody()).containsExactly(1, 2, 3);
verify(overview).downloadPhoto(41L, 101L);
verifyNoInteractions(containers);
}
@Test
@DisplayName("查询、新增和编辑按钮权限彼此独立")
void separatesButtonPermissions() {
@@ -50,6 +82,7 @@ class ContainerControllerContractTest {
"/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:",
"AWAITING_REVIEW", "version: {type: integer, minimum: 0}",
"downloadPath: {type: string, nullable: true",
"PDA完成及国内离仓后仍可读取ACTIVE历史照片",
"仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空")
.doesNotContain("?containerNo", "containerNo in: query");
}
@@ -33,15 +33,15 @@ class CustomerDimensionConfigControllerContractTest {
@Test
void internalInitializationRequiresTokenAndReturnsCreationIdentity() {
InitializeRequest request = new InitializeRequest(8L, 100L, "registration-1");
when(service.initializeDefault(8L, 100L, "registration-1"))
.thenReturn(new InitializeResponse(91L, true));
InitializeRequest request = new InitializeRequest(8L, 100L, "registration-1", "柏悦物流");
when(service.initializeDefault(8L, 100L, "registration-1", "柏悦物流"))
.thenReturn(new InitializeResponse(91L, 191L, true));
assertThatThrownBy(() -> internalController.initializeDefault("wrong", request))
.isInstanceOf(BizException.class);
assertThat(internalController.initializeDefault("test-token", request).data())
.isEqualTo(new InitializeResponse(91L, true));
verify(service).initializeDefault(8L, 100L, "registration-1");
.isEqualTo(new InitializeResponse(91L, 191L, true));
verify(service).initializeDefault(8L, 100L, "registration-1", "柏悦物流");
}
@Test
@@ -61,6 +61,22 @@ class InboundMeasurementEvidenceOpenApiContractTest {
assertThat(properties(schemas, "InboundMeasurementItemRequest")).containsKeys(
"inboundDetailId", "itemNo", "boxSpecification", "packageCount", "lengthCm",
"widthCm", "heightCm", "intelligentImageFileId", "remark");
String strategyPath = "/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/calculation-strategy";
assertThat(paths).containsKey(strategyPath);
Map<String, Object> strategyOperation = stringMap(stringMap(paths.get(strategyPath)).get("put"));
assertThat(strategyOperation.get("description").toString())
.contains("不覆盖整批测量数据", "不改变测量提交或审核状态", "客户已确认费用且该票已装柜完成");
assertThat(properties(schemas, "InboundMeasurementCalculationStrategyRequest"))
.containsKeys("measurementId", "version", "coefficientMode", "chargeRuleId");
String outerDimensionsPath = "/api/platform/v1/first-leg/inbounds/{inboundId}"
+ "/measurement/items/{itemId}/outer-dimensions";
assertThat(paths).containsKey(outerDimensionsPath);
Map<String, Object> outerDimensionsOperation = stringMap(
stringMap(paths.get(outerDimensionsPath)).get("put"));
assertThat(outerDimensionsOperation.get("description").toString())
.contains("不覆盖整批测量数据", "不改变测量提交或审核状态", "客户已确认费用且该票已装柜完成");
assertThat(properties(schemas, "InboundMeasurementOuterDimensionsRequest"))
.containsKeys("measurementId", "version", "outerLengthCm", "outerWidthCm", "outerHeightCm");
Map<String, Object> itemProperties = properties(schemas, "InboundMeasurementItem");
assertThat(itemProperties).containsKeys(
@@ -22,6 +22,8 @@ class InboundOrderControllerContractTest {
AuthenticatedUser.class, long.class, long.class);
assertAuthority("printCartonLabels", "hasAuthority('platform:firstleg:inbound:view')",
AuthenticatedUser.class, long.class, long.class, List.class, String.class);
assertAuthority("printCartonLabelsBatch", "hasAuthority('platform:firstleg:inbound:view')",
AuthenticatedUser.class, List.class, String.class);
Method page = PlatformInboundOrderController.class.getDeclaredMethod(
"page", AuthenticatedUser.class, Long.class, InboundOrderQuery.class);
@@ -47,6 +49,7 @@ class InboundOrderControllerContractTest {
.contains("/api/platform/inbounds:")
.contains("/api/platform/inbounds/{inboundId}:")
.contains("/api/platform/inbounds/{inboundId}/carton-label-pdf:")
.contains("/api/platform/inbounds/carton-label-pdf:")
.contains("/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans:")
.contains("required: false")
.contains("InboundOrderSummary:")
@@ -33,7 +33,7 @@ class ContainerCustomsControllerContractTest {
String yaml=new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
new Yaml().load(yaml);
assertThat(yaml).contains("ContainerView:","DocumentView:","FileView:","InboundView:","ActionRequest:","ScopeRequest:")
.contains("merchantStatus:","WAITING_UPLOAD","CUSTOMS_COMPLETED","loadingPlanId:")
.contains("merchantStatus:","WAITING_UPLOAD","CUSTOMS_COMPLETED","loadingPlanId:","hasActualCargo:","nullable: true")
.contains("LOADING_LIST_BUY_DECLARATION_ITEMS")
.doesNotContain("/cases/","CUSTOMS_DECLARATION");
}
@@ -23,6 +23,9 @@ class ContainerCustomsMapperContractTest {
@Test void actualCompletedLoadingItemsAndTaxModeAreTheOnlyTicketFacts() throws Exception {
String plan=sql("planInbounds",long.class);
assertThat(plan).contains("fl_loading_plan_item","fl_inbound_order","p.id=#{planId}","item.deleted=0");
String planIdentity=sql("plan",long.class);
assertThat(planIdentity).contains("fl_loading_plan","fl_container_booking","p.id=#{planId}")
.doesNotContain("fl_loading_plan_item","fl_inbound_order");
String missing=sql("missingRefundCount",long.class,long.class);
assertThat(missing).contains("i.tax_mode='TAX_REFUND'","fl_customs_customer_document_inbound","d.status='ACTIVE'");
}
@@ -44,6 +44,45 @@ class ContainerCustomsServiceTest {
assertMerchantOrderNotFound(7L,22L);
}
@Test void completedPlanWithoutActualCargoReturnsEmptyViewInsteadOfNotFound(){
var plan=new ContainerCustomsMapper.PlanRow(9L,"LP9",8L,"COMPLETED","CAB","C");
when(mapper.planInbounds(9L)).thenReturn(List.of());
when(mapper.plan(9L)).thenReturn(plan);
when(mapper.container(9L)).thenReturn(container(ContainerCustomsService.READY,0));
var view=service.platformView(9L);
assertThat(view.hasActualCargo()).isFalse();
assertThat(view.planNo()).isEqualTo("LP9");
assertThat(view.inbounds()).isEmpty();
assertThat(view.documents()).isEmpty();
assertThat(view.taxRefundCount()).isZero();
verify(mapper).insertContainer(anyLong(),eq(9L),eq(8L),eq(ContainerCustomsService.READY));
}
@Test void editingPlanReturnsNoCustomsAggregateInsteadOfConflict(){
when(mapper.planInbounds(9L)).thenReturn(List.of(new ContainerCustomsMapper.InboundRow(
9L,"LP9",8L,"EDITING","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND")));
assertThat(service.platformView(9L)).isNull();
verify(mapper,never()).insertContainer(anyLong(),anyLong(),anyLong(),anyString());
verify(mapper,never()).container(9L);
}
@Test void completedPlanWithoutActualCargoCannotStartCustoms(){
when(mapper.claimCommand(anyLong(),eq(5L),eq("START_CONTAINER_CUSTOMS_9"),eq("empty-plan"),anyString())).thenReturn(1);
when(mapper.planInbounds(9L)).thenReturn(List.of());
when(mapper.plan(9L)).thenReturn(new ContainerCustomsMapper.PlanRow(9L,"LP9",8L,"COMPLETED","CAB","C"));
when(mapper.container(9L)).thenReturn(container(ContainerCustomsService.READY,0));
assertThatThrownBy(()->service.start(9L,5L,"admin","empty-plan",new ActionRequest(0,true,null)))
.isInstanceOf(BizException.class).hasMessageContaining("没有实际货物,无需报关");
verify(mapper,never()).lockContainer(9L);
verify(mapper,never()).start(anyLong(),anyInt(),anyLong(),anyString());
}
@Test void merchantCompletedTaxRefundOrderReturnsContainerView(){
var row=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND");
when(mapper.completedPlanForOrder(7L,21L)).thenReturn(9L);
@@ -6,7 +6,6 @@ import com.baiyue.firstleg.loading.repository.LoadingRepository;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundProgress;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.service.FbaPricingService;
import com.baiyue.firstleg.service.InboundHoldGuard;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
@@ -29,7 +28,6 @@ class LoadingDeliveryServiceTest {
@MockBean LoadingCompletionGateService completionGate;
@MockBean InboundStatusHistoryMapper histories;
@MockBean CabinetLoadingEvidenceService cabinetEvidence;
@MockBean FbaPricingService pricingService;
@MockBean ObjectMapper objectMapper;
@MockBean InboundHoldGuard inboundHoldGuard;
@Resource LoadingDeliveryService service;
@@ -50,7 +48,6 @@ class LoadingDeliveryServiceTest {
new com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest(3, "done"));
verify(cabinetEvidence).requireComplete(99L);
verify(pricingService).autoConfirmAfterLoading(10L, 20L);
}
@Test
@@ -91,7 +88,6 @@ class LoadingDeliveryServiceTest {
verify(repository).findInboundForUpdate(10L, 21L);
verify(repository).completeLoadedChild(10L, 20L, 9L, 9);
verify(pricingService).autoConfirmAfterLoading(10L, 21L);
}
@Test
@@ -146,6 +146,24 @@ class OutboundDispatchServiceTest {
verify(carrierSchedules, times(1)).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin");
}
@Test
void confirmCreatesPlatformScopedCarrierTaskForConsolidatedBooking() {
CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class);
OutboundDispatchService service = service(carrierSchedules);
stubContainer();
when(repository.ocean(22L)).thenReturn(new OutboundDispatchReadMapper.OceanBinding(66L, "RELEASED", null));
when(repository.lockBindings(22L)).thenReturn(bindings());
when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "READY_FOR_DISPATCH", 0));
when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "READY_FOR_DISPATCH", 0));
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-platform-booking"), anyString())).thenReturn(true);
when(repository.confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull())).thenReturn(1);
when(repository.completeOperation(31L, 9L, "CONFIRM_DISPATCH", "confirm-platform-booking")).thenReturn(1);
service.confirm(22L, 9L, "admin", "confirm-platform-booking", new ActionRequest(0, null));
verify(carrierSchedules).ensureFromOutboundConfirm(null, 66L, 22L, 9L, "admin");
}
@Test
void confirmDoesNotWriteAnyRowWhenOneInboundGateFails() {
OutboundDispatchService service = serviceWithoutCarrier();
@@ -38,8 +38,6 @@ class CarrierScheduleQueryOpenApiContractTest {
.contains("cabinetNo, orderNo, bookingNo and bill of lading are never fallbacks")
.contains("latestActualNode:")
.contains("latestActualAt:")
.contains("manual actual node is equal to or earlier than latestActualNode")
.contains("strictly greater sequence than latestActualNode")
.contains("internal execution fields are intentionally absent")
.doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED")
.doesNotContain("enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]")
@@ -21,7 +21,7 @@ class CarrierScheduleQuerySqlContractTest {
.getAnnotation(Select.class).value());
assertThat(sql)
.contains("container_booking_id IN")
.contains("WHERE b.id IN")
.contains("f.actual_at IS NOT NULL")
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE'")
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT'")
@@ -29,9 +29,10 @@ class CarrierScheduleQuerySqlContractTest {
.contains("MAX(CASE WHEN f.standard_node='PICKUP_READY'")
.contains("MAX(CASE WHEN f.standard_node='PICKED_UP'")
.contains("MAX(CASE WHEN f.standard_node='EMPTY_RETURNED'")
.contains("ORDER BY q.container_booking_id")
.contains("ORDER BY b.id")
.doesNotContain("estimated_at");
assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(1);
assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(3);
assertThat(count(sql, "SELECT f2.id FROM fl_carrier_schedule_node_fact f2")).isEqualTo(1);
}
@Test
@@ -93,6 +93,24 @@ class CarrierScheduleNodeOutboxServiceTest {
verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
}
@Test
void directManualActualFactDoesNotRequireTrackingQuery() throws Exception {
CarrierScheduleNodeFactEntity fact = fact();
fact.setProviderCode("MANUAL"); fact.setProviderEventId("manual-event");
fact.setSourceReference("MANUAL:7");
service.publishContainerNode(null, 2088000000000000003L,
fact, "ZCSU7881970");
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(mapper).insert(captor.capture());
EventEnvelope envelope = objectMapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
assertThat(envelope.payload().path("queryId").isNull()).isTrue();
assertThat(envelope.payload().path("oceanBookingId").isNull()).isTrue();
assertThat(envelope.payload().path("sourceContainerBookingId").asText())
.isEqualTo("2088000000000000003");
}
@Test
void initialOutboundDepartureUsesVersionZeroWithoutEnvelopeFailure() throws Exception {
CarrierScheduleQueryEntity query = query();
@@ -210,6 +210,24 @@ class CarrierScheduleQueryServiceTest {
&& "国内确认出仓自动创建".equals(history.getReason())));
}
@Test
void platformOwnedConsolidatedBookingCreatesContainerTaskInTechnicalScope() {
OceanBookingEntity booking = new OceanBookingEntity();
booking.setCustomerId(null); booking.setCarrierCode("SML"); booking.setShippingCompanyName("SML");
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("ZCSU7881970");
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true);
CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(null, 10L, 20L, 0L, "SYSTEM");
assertThat(result.getCustomerId()).isZero();
verify(repository).findByBusinessScope(0L, 10L, 20L);
verify(repository).insert(argThat((CarrierScheduleQueryEntity task) -> task.getCustomerId() == 0L));
}
@Test
void outboundUnknownCarrierCreatesManualTaskAndPreservesBookingCarrierIdentity() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
@@ -298,7 +316,7 @@ class CarrierScheduleQueryServiceTest {
@Test
void outboundAndCarrierEnsureShareRequiredTransactionalRollbackBoundary() throws Exception {
Transactional carrierBoundary = CarrierScheduleQueryService.class.getMethod("ensureFromOutboundConfirm",
long.class, long.class, long.class, long.class, String.class).getAnnotation(Transactional.class);
Long.class, long.class, long.class, long.class, String.class).getAnnotation(Transactional.class);
Transactional outboundBoundary = OutboundDispatchService.class.getMethod("confirm",
long.class, long.class, String.class, String.class, ActionRequest.class).getAnnotation(Transactional.class);
@@ -433,34 +451,6 @@ class CarrierScheduleQueryServiceTest {
verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString());
}
@Test
void manualActualRejectsSameAndEarlierNodesIncludingSameKeyRetryWithoutDrift() {
CarrierScheduleQueryEntity task = task(0, 3);
task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP");
task.setLatestNodeObservedAt(NOW.minusDays(1));
when(repository.lock(1L)).thenReturn(task);
ManualNodeRequest sameNode = new ManualNodeRequest(3, "PICKED_UP", NOW, null,
NOW.plusDays(1), null, "Los Angeles", "重复确认提柜");
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-picked-up-again", sameNode))
.isInstanceOf(BizException.class)
.hasMessage("人工实际节点必须严格晚于当前最新实际节点");
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-picked-up-again", sameNode))
.isInstanceOf(BizException.class)
.hasMessage("人工实际节点必须严格晚于当前最新实际节点");
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-pickup-ready-late",
new ManualNodeRequest(3, "PICKUP_READY", NOW, null, NOW.plusDays(2), null,
"Los Angeles", "倒序补录可提柜")))
.isInstanceOf(BizException.class)
.hasMessage("人工实际节点必须严格晚于当前最新实际节点");
verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
verify(repository, never()).insert(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity.class));
verify(repository, never()).completeOperation(anyLong(), anyLong(), anyString(), anyString());
verify(nodeOutbox, never()).publish(any(), any(), anyString());
}
@Test
void manualEstimatedFactAdvancesVersionWithoutAdvancingActualProgress() {
CarrierScheduleQueryEntity before = task(0, 3);
@@ -531,7 +521,7 @@ class CarrierScheduleQueryServiceTest {
existing.setEventKey("ZIM:evt-duplicate"); existing.setRawPayloadHash("a".repeat(64));
when(repository.lock(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(false);
when(repository.node(1L, "ZIM:evt-duplicate")).thenReturn(existing);
when(repository.nodeByContainer(task.getContainerBookingId(), "ZIM:evt-duplicate")).thenReturn(existing);
ProviderObservationRequest replay = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN",
"AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "a".repeat(64));
@@ -210,20 +210,42 @@ class FbaPricingRepositoryRuleListTest {
.thenReturn(1);
assertThat(repository.markInboundCalculated(
10001L, 88L, "WAIT_PALLETIZE", "CBM", new java.math.BigDecimal("1.0000")))
10001L, 88L, "WAIT_PALLETIZE", "CBM", new java.math.BigDecimal("10.000"),
new java.math.BigDecimal("1.0000"), new java.math.BigDecimal("1.0000")))
.isEqualTo(1);
@SuppressWarnings("unchecked")
ArgumentCaptor<LambdaUpdateWrapper<InboundPricingEntity>> wrapper =
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture());
assertThat(wrapper.getValue().getSqlSet()).contains("feeStatus", "chargeableVolumeCbm")
assertThat(wrapper.getValue().getSqlSet()).contains(
"feeStatus", "chargeableWeightKg", "chargeableVolumeCbm")
.doesNotContain("processStatus");
assertThat(wrapper.getValue().getCustomSqlSegment())
.contains("tenantId", "id", "processStatus", "feeStatus");
assertThat(params(wrapper.getValue()).values()).contains(10001L, 88L, "WAIT_PALLETIZE");
}
@Test
void kilogramPricingStoresChargeableWeightAndSelectedVolumeTogether() {
when(pricing.update(org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any()))
.thenReturn(1);
assertThat(repository.markInboundCalculated(
10001L, 88L, "WAIT_SPLIT", "KG", new java.math.BigDecimal("297.000"),
new java.math.BigDecimal("9.0000"), new java.math.BigDecimal("1503.000")))
.isEqualTo(1);
@SuppressWarnings("unchecked")
ArgumentCaptor<LambdaUpdateWrapper<InboundPricingEntity>> wrapper =
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture());
assertThat(wrapper.getValue().getSqlSet())
.contains("chargeableWeightKg", "chargeableVolumeCbm");
assertThat(params(wrapper.getValue()).values()).contains(
new java.math.BigDecimal("1503.000"), new java.math.BigDecimal("9.0000"));
}
private Collection<Object> queryValues(Long tenantId, String ruleCode) {
when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of());
@@ -89,16 +89,16 @@ class LoadingPickItemMapperContractTest {
}
@Test
void pdaWritesAndReadsRequireWarehouseScope() throws Exception {
void pdaWritesAndReadsRelyOnUpstreamTaskScopeInsteadOfWarehouseScope() throws Exception {
Method list = LoadingPickItemMapper.class.getDeclaredMethod("selectTaskItemsWithAccount", long.class, long.class);
Method pick = LoadingPickItemMapper.class.getDeclaredMethod("pick", long.class, int.class,
int.class, long.class);
assertThat(list.getAnnotation(Select.class).value()[0])
.contains("booking.warehouse_id = #{warehouseId}")
.doesNotContain("booking.warehouse_id = #{warehouseId}")
.contains("plan_item.deleted = 0");
assertThat(pick.getAnnotation(Update.class).value()[0])
.contains("booking.warehouse_id = #{warehouseId}")
.doesNotContain("booking.warehouse_id = #{warehouseId}")
.contains("plan_item.deleted = 0");
}
}
@@ -0,0 +1,76 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(CustomerChargeRuleInitializationService.class)
class CustomerChargeRuleInitializationServiceTest {
@MockBean private FbaPricingRepository repository;
@Resource private CustomerChargeRuleInitializationService service;
@Test
void initializesDefaultWeightCoefficientRuleForNewCustomer() {
CustomerChargeRuleEntity result = service.initializeDefault(
8L, 100L, "iam-customer-create:8", "柏悦物流");
ArgumentCaptor<CustomerChargeRuleEntity> captor =
ArgumentCaptor.forClass(CustomerChargeRuleEntity.class);
verify(repository).insertRule(captor.capture());
CustomerChargeRuleEntity saved = captor.getValue();
assertThat(result).isSameAs(saved);
assertThat(saved.getTenantId()).isEqualTo(8L);
assertThat(saved.getCustomerId()).isEqualTo(8L);
assertThat(saved.getCustomerName()).isEqualTo("柏悦物流");
assertThat(saved.getRuleCode()).isEqualTo("DEFAULT-DIMENSION-WEIGHT");
assertThat(saved.getRuleVersion()).isEqualTo(1);
assertThat(saved.getDefaultRule()).isTrue();
assertThat(saved.getLengthFactor()).isEqualByComparingTo("1.100000");
assertThat(saved.getWidthFactor()).isEqualByComparingTo("1.100000");
assertThat(saved.getHeightFactor()).isEqualByComparingTo("1.100000");
assertThat(saved.getConversionFactor()).isEqualByComparingTo("167.000000");
assertThat(saved.getRemark()).contains(
"SMALL:30,20,20;系数:1.1;重量:5",
"EXTRA_LARGE:60,50,50;系数:1.1;重量:15");
assertThat(saved.getInitializationKey()).isEqualTo("iam-customer-create:8");
}
@Test
void retryReturnsRuleOwnedBySameInitializationWithoutDuplicating() {
CustomerChargeRuleEntity existing = new CustomerChargeRuleEntity();
existing.setId(191L);
when(repository.findRuleByInitializationKey(8L, "iam-customer-create:8"))
.thenReturn(existing);
assertThat(service.initializeDefault(8L, 100L, "iam-customer-create:8", "柏悦物流"))
.isSameAs(existing);
verify(repository, never()).insertRule(any());
}
@Test
void compensationDeletesOnlyAnUnchangedInitializedRule() {
CustomerChargeRuleEntity value = service.initializeDefault(
8L, 100L, "iam-customer-create:8", "柏悦物流");
value.setVersion(0);
when(repository.findRuleByInitializationKeyForUpdate(8L, "iam-customer-create:8"))
.thenReturn(value);
when(repository.deleteUnchangedInitializedRule(8L, "iam-customer-create:8"))
.thenReturn(1);
CustomerChargeRuleEntity locked = service.lockForCompensation(8L, "iam-customer-create:8");
assertThat(service.isUnchangedDefault(locked)).isTrue();
service.deleteUnchangedDefault(8L, "iam-customer-create:8");
verify(repository).deleteUnchangedInitializedRule(8L, "iam-customer-create:8");
}
}
@@ -22,6 +22,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@@ -32,6 +33,8 @@ import static org.mockito.Mockito.when;
class CustomerDimensionConfigServiceTest {
@MockBean
private CustomerDimensionConfigRepository repository;
@MockBean
private CustomerChargeRuleInitializationService chargeRuleInitialization;
@Resource
private CustomerDimensionConfigService service;
@@ -40,10 +43,12 @@ class CustomerDimensionConfigServiceTest {
List<CustomerDimensionConfigEntity> persisted = defaultConfigs("registration-1");
when(repository.findAll(8L)).thenReturn(List.of(), persisted);
when(repository.insertDefaults(any(), eq(100L))).thenReturn(4);
when(chargeRuleInitialization.initializeDefault(8L, 100L, "registration-1", "柏悦物流"))
.thenReturn(defaultChargeRule());
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1");
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1", "柏悦物流");
assertThat(result).isEqualTo(new InitializeResponse(91L, true));
assertThat(result).isEqualTo(new InitializeResponse(91L, 191L, true));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<CustomerDimensionConfigEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(repository).insertDefaults(captor.capture(), eq(100L));
@@ -67,10 +72,12 @@ class CustomerDimensionConfigServiceTest {
@Test
void retryAfterLostResponseKeepsCreationOwnershipForSameKey() {
when(repository.findAll(8L)).thenReturn(defaultConfigs("registration-1"));
when(chargeRuleInitialization.initializeDefault(8L, 100L, "registration-1", "柏悦物流"))
.thenReturn(defaultChargeRule());
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1");
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1", "柏悦物流");
assertThat(result).isEqualTo(new InitializeResponse(91L, true));
assertThat(result).isEqualTo(new InitializeResponse(91L, 191L, true));
verify(repository, never()).insertDefaults(any(), eq(100L));
}
@@ -78,10 +85,11 @@ class CustomerDimensionConfigServiceTest {
void differentInitializationKeyDoesNotTakeOwnershipOrOverwrite() {
when(repository.findAll(8L)).thenReturn(defaultConfigs("registration-1"));
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-2");
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-2", "柏悦物流");
assertThat(result).isEqualTo(new InitializeResponse(91L, false));
assertThat(result).isEqualTo(new InitializeResponse(91L, null, false));
verify(repository, never()).insertDefaults(any(), eq(100L));
verify(chargeRuleInitialization, never()).initializeDefault(anyLong(), anyLong(), any(), any());
}
@Test
@@ -173,10 +181,14 @@ class CustomerDimensionConfigServiceTest {
when(repository.findDefaultsForCompensation(8L, "registration-1"))
.thenReturn(defaultConfigs("registration-1"));
when(repository.deleteUnchangedDefaults(8L, "registration-1")).thenReturn(4);
when(chargeRuleInitialization.lockForCompensation(8L, "registration-1"))
.thenReturn(defaultChargeRule());
when(chargeRuleInitialization.isUnchangedDefault(any())).thenReturn(true);
service.compensateDefault(8L, "registration-1");
verify(repository).deleteUnchangedDefaults(8L, "registration-1");
verify(chargeRuleInitialization).deleteUnchangedDefault(8L, "registration-1");
}
@Test
@@ -228,4 +240,11 @@ class CustomerDimensionConfigServiceTest {
}
return values;
}
private static com.baiyue.firstleg.entity.CustomerChargeRuleEntity defaultChargeRule() {
com.baiyue.firstleg.entity.CustomerChargeRuleEntity value =
new com.baiyue.firstleg.entity.CustomerChargeRuleEntity();
value.setId(191L);
return value;
}
}
@@ -77,48 +77,6 @@ class FbaPricingServiceTest {
verify(repository, never()).findLatestFee(TENANT, INBOUND);
}
@Test
@DisplayName("装柜完成后系统自动确认客户未确认的已发送费用")
void automaticallyConfirmsSentFeeAfterLoading() {
InboundFeeEntity sent = savedFee("SENT");
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent);
when(repository.confirmFeeByCustomer(TENANT, 501L, 1, 0L,
"装柜完成自动确认", NOW)).thenReturn(1);
service.autoConfirmAfterLoading(TENANT, INBOUND);
verify(repository).confirmFeeByCustomer(TENANT, 501L, 1, 0L,
"装柜完成自动确认", NOW);
}
@Test
@DisplayName("客户已经确认时装柜完成不覆盖原确认信息")
void keepsExistingCustomerConfirmationAfterLoading() {
InboundFeeEntity sent = savedFee("SENT");
sent.setCustomerConfirmedBy(88L);
sent.setCustomerConfirmedByName("客户成员");
sent.setCustomerConfirmedAt(NOW.minusHours(1));
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent);
service.autoConfirmAfterLoading(TENANT, INBOUND);
verify(repository, never()).confirmFeeByCustomer(anyLong(), anyLong(), anyInt(),
anyLong(), anyString(), any());
}
@Test
@DisplayName("没有已发送费用时装柜完成不受影响")
void ignoresMissingOrUnsentFeeAfterLoading() {
when(repository.findLatestFeeForUpdate(TENANT, INBOUND))
.thenReturn(null, savedFee("CONFIRMED"));
service.autoConfirmAfterLoading(TENANT, INBOUND);
service.autoConfirmAfterLoading(TENANT, INBOUND);
verify(repository, never()).confirmFeeByCustomer(anyLong(), anyLong(), anyInt(),
anyLong(), anyString(), any());
}
@Test
@DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商")
void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() {
@@ -258,7 +216,7 @@ class FbaPricingServiceTest {
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_SPLIT", "KG",
new BigDecimal("167.000"));
new BigDecimal("100.000"), new BigDecimal("1.0000"), new BigDecimal("167.000"));
verify(feeDetails).materializeCalculatedFreight(TENANT, INBOUND, 501L, OPERATOR, NOW);
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
}
@@ -289,17 +247,27 @@ class FbaPricingServiceTest {
}
@Test
@DisplayName("装柜完成后禁止确认费用")
void rejectsConfirmAfterLoadingCompleted() {
@DisplayName("装柜完成但客户未确认时仍允许确认新费用版本")
void confirmsNewFeeVersionAfterLoadingCompleted() {
InboundPricingEntity inbound = inbound("10.000", "1.0000");
inbound.setProcessStatus("LOADED");
inbound.setFeeStatus("CALCULATED");
InboundFeeEntity fee = savedFee("CALCULATED");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee);
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CONFIRM", "confirm-loaded"))
.thenReturn(null);
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(899L); return null; })
.when(repository).insertIdempotency(any());
when(repository.confirmFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1);
when(repository.markInboundConfirmed(TENANT, INBOUND)).thenReturn(1);
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
when(repository.findFee(TENANT, 501L)).thenReturn(fee);
assertThatThrownBy(() -> service.confirm(TENANT, OPERATOR, INBOUND, "confirm-loaded"))
.isInstanceOf(BizException.class)
.hasMessageContaining("装柜流程");
verify(repository, never()).markInboundConfirmed(TENANT, INBOUND);
FeeSnapshotResponse response = service.confirm(TENANT, OPERATOR, INBOUND, "confirm-loaded");
assertThat(response.nextProcessStatus()).isEqualTo("LOADED");
verify(repository).markInboundConfirmed(TENANT, INBOUND);
}
@Test
@@ -331,15 +299,15 @@ class FbaPricingServiceTest {
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_PALLETIZE");
verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_PALLETIZE", "CBM",
new BigDecimal("1.0000"));
new BigDecimal("10.000"), new BigDecimal("1.0000"), new BigDecimal("1.0000"));
}
@Test
@DisplayName("客户未确认时待装柜订单可重新计算并替换已发送费用")
@DisplayName("客户未确认时装柜完成订单仍可重新计算并替换已发送费用")
void recalculatesSentFeeBeforeCustomerConfirmation() {
readyRepository();
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_PALLET");
inbound.setProcessStatus("LOADED");
inbound.setFeeStatus("SENT");
InboundFeeEntity sent = savedFee("SENT");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
@@ -354,18 +322,18 @@ class FbaPricingServiceTest {
assertThat(response.feeVersion()).isEqualTo(2);
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_PALLET");
assertThat(response.nextProcessStatus()).isEqualTo("LOADED");
verify(repository).supersedeCurrent(TENANT, 501L, "SENT");
verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_PALLET", "CBM",
new BigDecimal("1.0000"));
verify(repository).markInboundCalculated(TENANT, INBOUND, "LOADED", "CBM",
new BigDecimal("10.000"), new BigDecimal("1.0000"), new BigDecimal("1.0000"));
}
@Test
@DisplayName("客户已确认时拒绝重新计算已发送费用")
@DisplayName("客户已确认且装柜完成时拒绝重新计算已发送费用")
void rejectsRecalculationAfterCustomerConfirmation() {
readyRepository();
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_PALLET");
inbound.setProcessStatus("LOADED");
inbound.setFeeStatus("SENT");
InboundFeeEntity sent = savedFee("SENT");
sent.setCustomerConfirmedAt(NOW);
@@ -493,6 +461,7 @@ class FbaPricingServiceTest {
inbound.setProcessStatus("WAIT_SPLIT");
inbound.setFeeStatus("SENT");
InboundFeeEntity current = savedFee("SENT");
current.setCustomerConfirmedAt(NOW.minusHours(1));
AtomicReference<InboundFeeEntity> inserted = new AtomicReference<>();
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current);
@@ -501,7 +470,8 @@ class FbaPricingServiceTest {
.when(repository).insertIdempotency(any());
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.supersedeCurrent(TENANT, 501L, "SENT")).thenReturn(1);
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("SENT"), eq("KG"), any()))
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("SENT"),
any(), any(), eq("KG"), any()))
.thenReturn(1);
doAnswer(invocation -> {
InboundFeeEntity fee = invocation.getArgument(0);
@@ -561,10 +531,10 @@ class FbaPricingServiceTest {
}
@Test
@DisplayName("客户已确认费用时拒绝平台再次修改")
@DisplayName("客户已确认且装柜完成时拒绝平台再次修改")
void rejectsAdjustmentAfterCustomerConfirmation() {
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_SPLIT");
inbound.setProcessStatus("LOADED");
InboundFeeEntity current = savedFee("SENT");
current.setCustomerConfirmedAt(NOW);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
@@ -596,7 +566,8 @@ class FbaPricingServiceTest {
.when(repository).insertIdempotency(any());
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1);
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("CALCULATED"), eq("KG"), any()))
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("CALCULATED"),
any(), any(), eq("KG"), any()))
.thenReturn(1);
doAnswer(invocation -> {
InboundFeeEntity fee = invocation.getArgument(0);
@@ -655,7 +626,8 @@ class FbaPricingServiceTest {
.when(repository).insertIdempotency(any());
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1);
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("CALCULATED"), eq("KG"), any()))
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("CALCULATED"),
any(), any(), eq("KG"), any()))
.thenReturn(1);
doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(504L); return null; })
.when(repository).insertFee(any());
@@ -803,7 +775,8 @@ class FbaPricingServiceTest {
when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2);
when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1);
when(repository.supersedeCurrent(eq(TENANT), anyLong(), anyString())).thenReturn(1);
when(repository.markInboundContainerFeeRecalculated(eq(TENANT), anyLong(), anyString(), anyString(), any()))
when(repository.markInboundContainerFeeRecalculated(eq(TENANT), anyLong(), anyString(),
any(), any(), anyString(), any()))
.thenReturn(1);
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(
regionEstimate("200", "8", "1600"), regionEstimate("200", "8", "1600"));
@@ -855,7 +828,8 @@ class FbaPricingServiceTest {
when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2);
when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1);
when(repository.supersedeCurrent(TENANT, 502L, "CALCULATED")).thenReturn(1);
when(repository.markInboundContainerFeeRecalculated(eq(TENANT), eq(30002L), anyString(), anyString(), any()))
when(repository.markInboundContainerFeeRecalculated(eq(TENANT), eq(30002L), anyString(),
any(), any(), anyString(), any()))
.thenReturn(1);
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("180", "8", "1440"));
when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn(
@@ -939,7 +913,8 @@ class FbaPricingServiceTest {
doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(501L); return null; })
.when(repository).insertFee(any());
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(1);
when(repository.markInboundCalculated(eq(TENANT), eq(INBOUND), any(), any(), any())).thenReturn(1);
when(repository.markInboundCalculated(eq(TENANT), eq(INBOUND), any(), any(), any(), any(), any()))
.thenReturn(1);
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
}
@@ -21,6 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class InboundCartonLabelPrintServiceTest {
@@ -118,6 +119,39 @@ class InboundCartonLabelPrintServiceTest {
org.mockito.ArgumentMatchers.eq("request-batch"), org.mockito.ArgumentMatchers.any());
}
@Test
void platformBatchCombinesLabelsAcrossCustomers() {
when(labels.list(10001L, 70001L, null)).thenReturn(batch());
when(labels.list(10002L, 70002L, null)).thenReturn(batch());
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.eq("platform-batch"), org.mockito.ArgumentMatchers.any()))
.thenReturn(new ResponseEntity<>(new byte[] {1, 2, 3}, headers,
org.springframework.http.HttpStatus.OK));
ResponseEntity<byte[]> response = service.generateForPlatformInbounds(List.of(
new InboundCartonLabels.PlatformBatchItem(10001L, 70001L),
new InboundCartonLabels.PlatformBatchItem(10002L, 70002L)), "platform-batch");
assertThat(response.getHeaders().getFirst("X-Carton-Label-Order-Count")).isEqualTo("2");
assertThat(response.getBody()).containsExactly(1, 2, 3);
}
@Test
void platformBatchFailsWhenAnySelectedInboundCannotGenerateLabels() {
when(labels.list(10001L, 70001L, null)).thenReturn(batch());
when(labels.list(10002L, 70002L, null))
.thenThrow(new BizException(ResultCode.NOT_FOUND, "箱唛入库单不存在"));
assertThatThrownBy(() -> service.generateForPlatformInbounds(List.of(
new InboundCartonLabels.PlatformBatchItem(10001L, 70001L),
new InboundCartonLabels.PlatformBatchItem(10002L, 70002L)), "platform-batch"))
.isInstanceOf(BizException.class)
.hasMessage("箱唛入库单不存在");
verifyNoInteractions(print);
}
private InboundCartonLabels.Batch batch() {
InboundCartonLabels.Label label = new InboundCartonLabels.Label(
1L, 2L, 1, "音箱", 19, "PCHK2608040174#19-1", 19, 1,
@@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
@@ -233,6 +234,21 @@ class InboundCartonLabelServiceTest {
.hasMessageContaining("订单服务暂不可用");
}
@Test
void convertsOrderCargoCircuitBreakerFailureToActionableBusinessError() {
InboundOrderEntity inbound = inbound();
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(new ArrayList<>());
when(repository.findByInbound(10001L, 70001L)).thenReturn(new ArrayList<>());
when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L))
.thenThrow(new NoFallbackAvailableException("order unavailable",
new IllegalStateException("downstream failure")));
assertThatThrownBy(() -> service.getOrCreate(10001L, 70001L))
.isInstanceOf(BizException.class)
.hasMessage("订单服务暂不可用,无法生成唛头");
}
@Test
void repairsMissingHistoricalInboundFromOwnedOrderSnapshot() {
var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand(
@@ -11,12 +11,14 @@ import com.baiyue.firstleg.dto.MeasurementBatchCommand;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -57,6 +59,7 @@ import static org.mockito.Mockito.when;
class InboundMeasurementServiceTest {
@MockBean private InboundMeasurementRepository repository;
@MockBean private MeasurementBatchService batchService;
@MockBean private FbaPricingRepository pricingRepository;
@MockBean private FbaMasterDataService fbaMasterDataService;
@MockBean private OrderCargoClient orderCargoClient;
@MockBean private MeasurementImageClient imageClient;
@@ -90,6 +93,85 @@ class InboundMeasurementServiceTest {
verify(repository, never()).listWeights(anyLong(), anyLong());
}
@Test
void updatesSubmittedCalculationStrategyWithoutReplacingMeasurementBatch() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L);
measurement.setTenantId(1L);
measurement.setInboundId(10L);
measurement.setStatus("SUBMITTED");
measurement.setVersion(5);
measurement.setTotalVolumeCbm(new BigDecimal("2.5000"));
measurement.setVolumeWeightKg(new BigDecimal("417.500"));
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setId(31L);
item.setTenantId(1L);
item.setMeasurementId(30L);
when(repository.lockInbound(1L, 10L)).thenReturn(inbound);
when(repository.lockMeasurement(1L, 10L, 30L)).thenReturn(measurement);
when(repository.listItems(1L, 30L)).thenReturn(List.of(item));
when(repository.updateMeasurement(measurement)).thenReturn(true);
when(repository.updateInbound(inbound)).thenReturn(true);
var response = service.updateCalculationStrategy(1L, 10L,
new InboundMeasurementRequests.UpdateCalculationStrategyRequest(30L, 5, "ADD", null));
assertThat(response.status()).isEqualTo("SUBMITTED");
verify(batchService).recalculateStrategy(1L, measurement, List.of(item), "ADD", null);
verify(repository, never()).replaceItems(anyLong(), anyLong(), any());
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("2.5000");
}
@Test
void locksCalculationStrategyOnlyAfterCustomerConfirmationAndLoadingCompletion() {
InboundOrderEntity inbound = inbound("LOADED");
InboundFeeEntity fee = new InboundFeeEntity();
fee.setCustomerConfirmedAt(LocalDateTime.now());
when(repository.lockInbound(1L, 10L)).thenReturn(inbound);
when(pricingRepository.findLatestFeeForUpdate(1L, 10L)).thenReturn(fee);
assertThatThrownBy(() -> service.updateCalculationStrategy(1L, 10L,
new InboundMeasurementRequests.UpdateCalculationStrategyRequest(30L, 5, "ACTUAL", null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("客户已确认且该票已装柜完成");
verify(repository, never()).lockMeasurement(anyLong(), anyLong(), anyLong());
}
@Test
void updatesSubmittedWarehouseOuterDimensionsWithoutReplacingMeasurementBatch() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L);
measurement.setTenantId(1L);
measurement.setInboundId(10L);
measurement.setStatus("SUBMITTED");
measurement.setVersion(5);
measurement.setTotalVolumeCbm(new BigDecimal("1.8000"));
measurement.setVolumeWeightKg(new BigDecimal("300.600"));
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setId(31L);
item.setTenantId(1L);
item.setMeasurementId(30L);
when(repository.lockInbound(1L, 10L)).thenReturn(inbound);
when(repository.lockMeasurement(1L, 10L, 30L)).thenReturn(measurement);
when(repository.listItems(1L, 30L)).thenReturn(List.of(item));
when(repository.updateMeasurement(measurement)).thenReturn(true);
when(repository.updateInbound(inbound)).thenReturn(true);
var response = service.updateOuterDimensions(1L, 10L, 31L,
new InboundMeasurementRequests.UpdateOuterDimensionsRequest(
30L, 5, new BigDecimal("30"), new BigDecimal("20"), new BigDecimal("20")));
assertThat(response.status()).isEqualTo("SUBMITTED");
assertThat(response.itemId()).isEqualTo(31L);
verify(batchService).recalculateOuterDimensions(1L, measurement, List.of(item), 31L,
new BigDecimal("30"), new BigDecimal("20"), new BigDecimal("20"));
verify(repository, never()).replaceItems(anyLong(), anyLong(), any());
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("1.8000");
}
@Test
void correctingWeightKeepsOcrOriginalAndWritesImmutableRevision() {
InboundOrderEntity inbound = inbound("MEASURING");
@@ -1089,7 +1171,7 @@ class InboundMeasurementServiceTest {
}
@Test
void submitDoesNotBlockOnUnconfirmedWeight() {
void submitRejectsWhenNoWeightImageHasBeenConfirmed() {
InboundOrderEntity inbound = inbound("MEASURING");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L);
@@ -1103,16 +1185,37 @@ class InboundMeasurementServiceTest {
when(repository.listItems(1L, 30L)).thenReturn(List.of(item));
when(repository.listWeights(1L, 10L)).thenReturn(List.of(
weight(20L, "20", "KG", "PENDING_CONFIRM", 0)));
when(repository.updateMeasurement(measurement)).thenReturn(true);
when(repository.updateInbound(inbound)).thenReturn(true);
assertThatThrownBy(() -> service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 0)))
.isInstanceOf(BizException.class)
.hasMessageContaining("至少上传并确认一张称重图片");
InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 0));
verify(repository, never()).updateMeasurement(any());
verify(repository, never()).updateInbound(any());
}
assertThat(result.processStatus()).isEqualTo("MEASURE_PENDING");
assertThat(inbound.getActualGrossWeightKg()).isNull();
verify(repository).updateMeasurement(measurement);
verify(repository).updateInbound(inbound);
@Test
void submitRejectsWhenThereIsNoWeightImage() {
InboundOrderEntity inbound = inbound("MEASURING");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L);
measurement.setStatus("DRAFT");
measurement.setVersion(0);
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setPackageCount(1);
item.setSourceImageFileId(500L);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement);
when(repository.listItems(1L, 30L)).thenReturn(List.of(item));
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
assertThatThrownBy(() -> service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 0)))
.isInstanceOf(BizException.class)
.hasMessageContaining("至少上传并确认一张称重图片");
verify(repository, never()).updateMeasurement(any());
verify(repository, never()).updateInbound(any());
}
@Test
@@ -521,16 +521,22 @@ class LoadingPickServiceTest {
}
@Test
void taskDetailRejectsWarehouseThatDoesNotOwnTheContainer() {
void taskDetailDoesNotRecheckWarehouseOwnership() {
LoadingPickTaskEntity task = task();
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L)).thenReturn(List.of(item()));
when(repository.items(20L, 999L)).thenReturn(List.of(item()));
when(repository.cartonLabels(20L)).thenReturn(List.of());
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L)));
when(palletAllocations.list(101L, 50L)).thenReturn(List.of(allocation(50L, 2, 202L)));
assertThatThrownBy(() -> service.task(20L, 999L))
.isInstanceOf(BizException.class).hasMessageContaining("不属于当前仓库");
var result = service.task(20L, 999L);
verify(repository, never()).items(20L, 999L);
verify(repository, never()).cartonLabels(20L);
verify(completionCommands, never()).latest(20L);
assertThat(result.id()).isEqualTo(20L);
assertThat(result.items()).hasSize(1);
assertThat(result.items().get(0).inventoryResources()).singleElement()
.extracting("availablePackageCount").isEqualTo(2);
}
@Test
@@ -1412,6 +1418,40 @@ class LoadingPickServiceTest {
verify(settlementService, never()).settle(20L, 9L, "operator");
}
@Test
void batchUsesConfirmedInboundInventoryWhenContainerLoadsAcrossWarehouses() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
item.setPlannedPackageCount(250);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L)).thenReturn(List.of(item));
when(repository.items(20L, 101L)).thenReturn(List.of(item));
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(1L, 50L)));
when(palletAllocations.list(1L, 50L)).thenReturn(List.of(allocation(50L, 250, 202L)));
when(repository.pick(30L, 250, 0, 101L)).thenReturn(1);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.touchTask(20L, 0)).thenReturn(1);
var snapshot = service.inventorySnapshot(20L, 101L);
assertThat(snapshot.warehouseId()).isEqualTo(101L);
assertThat(snapshot.inbounds()).singleElement().satisfies(inbound -> {
assertThat(inbound.availablePackageCount()).isEqualTo(250);
assertThat(inbound.resources()).singleElement()
.extracting("availablePackageCount")
.isEqualTo(250);
});
service.batch(20L, "batch-cross-warehouse", new BatchRequest(
101L, 10L, 99L, 0, snapshot.snapshotToken(), List.of(
new BatchItemRequest(30L, "PICK", 250, null, 0, null, null))), 9L, "operator");
verify(repository).pick(30L, 250, 0, 101L);
verify(repository).touchTask(20L, 0);
}
@Test
void rejectsBatchTaskVersionConflictBeforeWriting() {
LoadingPickTaskEntity task = task();
@@ -1561,6 +1601,11 @@ class LoadingPickServiceTest {
}
private static InboundPalletAllocationEntity allocation(long inboundId, int packageCount) {
return allocation(inboundId, packageCount, 101L);
}
private static InboundPalletAllocationEntity allocation(long inboundId, int packageCount,
long warehouseId) {
InboundPalletAllocationEntity allocation = new InboundPalletAllocationEntity();
allocation.setId(72L);
allocation.setTenantId(1L);
@@ -1568,7 +1613,7 @@ class LoadingPickServiceTest {
allocation.setBlockId(71L);
allocation.setSlotNo(1);
allocation.setAllocationNo(1);
allocation.setWarehouseId(101L);
allocation.setWarehouseId(warehouseId);
allocation.setPalletId(73L);
allocation.setLocationId(74L);
allocation.setLocationCode("A-01");
@@ -26,6 +26,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -97,6 +98,81 @@ class MeasurementBatchServiceTest {
assertThat(items.getValue().get(0).getInboundDetailId()).isEqualTo(80001L);
}
@Test
void recalculatesSubmittedItemsInPlaceWithoutChangingTheirIdentityOrStatus() {
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(100L);
measurement.setTenantId(1L);
measurement.setInboundId(10L);
measurement.setSourceType("MANUAL");
measurement.setStatus("SUBMITTED");
measurement.setVersion(4);
measurement.setCreatedBy(9L);
InboundMeasurementItemEntity existing = new InboundMeasurementItemEntity();
existing.setId(200L);
existing.setTenantId(1L);
existing.setMeasurementId(100L);
existing.setInboundDetailId(80001L);
existing.setBoxCode("RK-001#1");
existing.setItemNo("001");
existing.setBoxSpecification("40x30x20cm");
existing.setPackageCount(2);
existing.setLengthCm(new BigDecimal("40"));
existing.setWidthCm(new BigDecimal("30"));
existing.setHeightCm(new BigDecimal("20"));
when(repository.updateItem(existing)).thenReturn(true);
service.recalculateStrategy(1L, measurement, List.of(existing), "ACTUAL", null);
assertThat(existing.getId()).isEqualTo(200L);
assertThat(existing.getBoxCode()).isEqualTo("RK-001#1");
assertThat(existing.getOuterLengthCm()).isEqualByComparingTo("40.000");
assertThat(measurement.getStatus()).isEqualTo("SUBMITTED");
assertThat(measurement.getTotalVolumeCbm()).isEqualByComparingTo("0.0480");
verify(repository).updateItem(existing);
verify(repository, never()).replaceItems(anyLong(), anyLong(), any());
}
@Test
void recalculatesOneSubmittedWarehouseOuterDimensionRowInPlace() {
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(100L);
measurement.setTenantId(1L);
measurement.setInboundId(10L);
measurement.setSourceType("MANUAL");
measurement.setStatus("SUBMITTED");
measurement.setVersion(4);
measurement.setCreatedBy(9L);
InboundMeasurementItemEntity existing = new InboundMeasurementItemEntity();
existing.setId(200L);
existing.setTenantId(1L);
existing.setMeasurementId(100L);
existing.setInboundDetailId(80001L);
existing.setBoxCode("RK-001#1");
existing.setItemNo("001");
existing.setBoxSpecification("40x30x20cm");
existing.setPackageCount(2);
existing.setLengthCm(new BigDecimal("40"));
existing.setWidthCm(new BigDecimal("30"));
existing.setHeightCm(new BigDecimal("20"));
when(repository.updateItem(existing)).thenReturn(true);
service.recalculateOuterDimensions(1L, measurement, List.of(existing), 200L,
new BigDecimal("30"), new BigDecimal("20"), new BigDecimal("20"));
assertThat(existing.getId()).isEqualTo(200L);
assertThat(existing.getBoxCode()).isEqualTo("RK-001#1");
assertThat(existing.getManualOuterLengthCm()).isEqualByComparingTo("30");
assertThat(existing.getManualOuterWidthCm()).isEqualByComparingTo("20");
assertThat(existing.getManualOuterHeightCm()).isEqualByComparingTo("20");
assertThat(existing.getOuterLengthCm()).isEqualByComparingTo("30.000");
assertThat(existing.getUnitVolumeCbm()).isEqualByComparingTo("0.0120");
assertThat(measurement.getStatus()).isEqualTo("SUBMITTED");
assertThat(measurement.getTotalVolumeCbm()).isEqualByComparingTo("0.0240");
verify(repository).updateItem(existing);
verify(repository, never()).replaceItems(anyLong(), anyLong(), any());
}
@Test
void automaticallyAppliesCustomerDefaultRuleToActualMeasurement() {
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
@@ -1,6 +1,7 @@
package com.baiyue.firstleg.terminaldrayage.controller;
import org.junit.jupiter.api.Test;
import org.apache.ibatis.annotations.Select;
import org.springframework.security.access.prepost.PreAuthorize;
import java.nio.file.Files;
@@ -50,10 +51,24 @@ class TerminalDrayageOpenApiContractTest {
"platform:firstleg:terminal-drayage:pickup:confirm",
"platform:firstleg:terminal-drayage:empty-return:confirm",
"expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt",
"emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays");
"emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays",
"船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情",
"允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件");
assertThat(yaml).contains("description: 卸柜完成时间加72小时");
}
@Test
void terminalDrayageContextsUseConfirmedOutboundAsTheOverseasBoundary() throws Exception {
var mapper = com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class;
for (String methodName : java.util.List.of("context", "lockContext", "contexts")) {
var method = java.util.Arrays.stream(mapper.getDeclaredMethods())
.filter(value -> methodName.equals(value.getName())).findFirst().orElseThrow();
String sql = String.join("\n", method.getAnnotation(Select.class).value());
assertThat(sql).contains("FROM fl_outbound_dispatch", "d.confirmed_at IS NOT NULL",
"d.reversed_at IS NULL", "d.status='WAITING_FOR_LOADING'");
}
}
private static void assertPermission(String methodName, String permission) {
var method = java.util.Arrays.stream(PlatformTerminalDrayageController.class.getDeclaredMethods())
.filter(value -> methodName.equals(value.getName())).findFirst().orElseThrow();
@@ -61,7 +61,7 @@ class TerminalDrayageSqlContractTest {
"UNIQUE KEY uk_terminal_drayage_operation(container_booking_id,idempotency_key)",
"服务端按卸柜完成时间加72小时计算的预计还柜时间");
assertThat(sql).doesNotContain("ON DELETE CASCADE");
assertThat(mapper).contains("WHERE b.id=#{containerBookingId} FOR UPDATE",
assertThat(mapper).contains("WHERE b.id=#{containerBookingId}", "FOR UPDATE",
"WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}")
.doesNotContain("fl_carrier_schedule_query", "fl_carrier_schedule_node_fact");
}
@@ -84,6 +84,35 @@ class ContainerWorkbenchServiceTest {
verifyNoInteractions(carrierSchedules);
}
@Test
void overseasDetailKeepsNewlyDepartedContainerBeforeCarrierTrackingExists() {
ContainerBookingEntity container = container();
container.setOutboundConfirmedAt(LocalDateTime.of(2026, 9, 10, 10, 50));
Set<String> permissions = Set.of("platform:firstleg:terminal-drayage:list",
"platform:firstleg:terminal-drayage:document:edit");
when(repository.find(11L)).thenReturn(container);
when(repository.departed(11L)).thenReturn(true);
var waiting = waitingDrayage();
when(terminalDrayages.views(List.of(11L))).thenReturn(List.of(waiting));
when(terminalDrayages.authorize(List.of(waiting), permissions)).thenReturn(List.of(waiting));
var result = service.overseasDetail(11L, permissions);
assertThat(result.workbenchStage()).isEqualTo("DEPARTED_WAREHOUSE");
assertThat(result.actualFacts()).isNull();
assertThat(result.terminalDrayage()).isEqualTo(waiting);
assertThat(result.allowedActions()).containsExactly("MANAGE_DESTINATION_DOCUMENTS");
verify(terminalDrayages, never()).detail(11L);
verifyNoInteractions(carrierSchedules);
}
private static com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View waitingDrayage() {
return new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View(
11L, "MSCU1234567", 21L, "WAITING_FOR_ARRIVAL", 0, null, null, null,
null, null, null, null, new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.Warning(
"NOT_APPLICABLE", null, 0, 0), List.of(), null, null);
}
@Test
void realOverseasListAndDetailProjectBothEmptyReturnActions() throws Exception {
verifyRealEmptyReturnProjection(true, true);
@@ -139,8 +168,8 @@ class ContainerWorkbenchServiceTest {
var serialized = json.readTree(json.writeValueAsString(com.baiyue.common.core.api.ApiResponse.success(page)));
assertThat(serialized.at("/data/records/0/allowedActions")).isEqualTo(json.valueToTree(expected));
assertThat(serialized.at("/data/records/0/terminalDrayage/allowedActions")).isEqualTo(json.valueToTree(expected));
verify(mapper).findAll(List.of(11L));
verify(mapper).contexts(List.of(11L));
verify(mapper, times(2)).findAll(List.of(11L));
verify(mapper, times(2)).contexts(List.of(11L));
}
@Test
@@ -2,7 +2,7 @@ package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
@@ -125,8 +125,8 @@ class TerminalDrayageServiceTest {
assertThat(result.status()).isEqualTo("PICKED_UP");
assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0));
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class);
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
ArgumentCaptor<ContainerManualNodeRequest> node = ArgumentCaptor.forClass(ContainerManualNodeRequest.class);
verify(carrierSchedules).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
assertThat(node.getValue().standardNode()).isEqualTo("PICKED_UP");
verify(mapper, times(1)).updateAggregate(any(), anyInt(), anyLong(), anyString());
}
@@ -144,7 +144,7 @@ class TerminalDrayageServiceTest {
new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 10, 0), null)))
.isInstanceOf(BizException.class)
.hasMessage("海外卸柜收货完成后才能确认还柜");
verify(carrierSchedules, never()).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
verify(carrierSchedules, never()).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
}
@Test
@@ -212,7 +212,7 @@ class TerminalDrayageServiceTest {
assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN");
assertThat(service.recordEmptyReturn(11L, 7L, "admin", "return-new", request)).isEqualTo(result);
verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class));
verify(carrierSchedules, times(1)).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
verify(carrierSchedules, times(1)).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
}
@Test
@@ -222,7 +222,7 @@ class TerminalDrayageServiceTest {
new ActualTimeRequest(0, LocalDateTime.of(2026, 9, 2, 23, 0), null)))
.isInstanceOf(BizException.class).hasMessage("实际还柜时间不能早于提柜或卸柜完成时间");
verify(mapper, never()).insert(any(TerminalDrayageEntity.class));
verify(carrierSchedules, never()).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
verify(carrierSchedules, never()).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
}
@Test
@@ -243,13 +243,26 @@ class TerminalDrayageServiceTest {
}
@Test
void bulkViewOmitsContainerWhenActualFactPortHasNoTrackingRecord() {
void readsDepartedContainerBeforeCarrierTrackingRecordExists() {
when(mapper.contexts(List.of(11L))).thenReturn(List.of(
new Context(11L, "MSCU1234567", 21L, "ACTIVE", null)));
when(mapper.findAll(List.of(11L))).thenReturn(List.of());
when(mapper.context(11L)).thenReturn(
new Context(11L, "MSCU1234567", 21L, "ACTIVE", null));
when(mapper.find(11L)).thenReturn(null);
assertThat(service.views(List.of(11L))).isEmpty();
verify(carrierSchedules).containerActualFacts(List.of(11L));
var bulk = service.views(List.of(11L));
var detail = service.detail(11L);
assertThat(bulk).singleElement().satisfies(view -> {
assertThat(view.status()).isEqualTo("WAITING_FOR_ARRIVAL");
assertThat(view.actualFacts()).isNull();
assertThat(view.allowedActions()).isEmpty();
});
assertThat(detail.status()).isEqualTo("WAITING_FOR_ARRIVAL");
assertThat(detail.actualFacts()).isNull();
assertThat(detail.allowedActions()).isEmpty();
verify(carrierSchedules, times(2)).containerActualFacts(List.of(11L));
}
@Test
@@ -338,8 +351,8 @@ class TerminalDrayageServiceTest {
assertThat(result.status()).isEqualTo("EMPTY_RETURNED");
assertThat(result.estimatedEmptyReturnAt()).isEqualTo(unloadedAt.plusHours(72));
assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED");
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class);
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
ArgumentCaptor<ContainerManualNodeRequest> node = ArgumentCaptor.forClass(ContainerManualNodeRequest.class);
verify(carrierSchedules).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED");
assertThat(node.getValue().reason()).isEqualTo("已还柜");
assertThat(node.getValue().actualAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 9, 0));