Merge test fixes into dev dropship flow
This commit is contained in:
+1
-1
@@ -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()
|
||||
|
||||
+34
@@ -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 = "确认测量并提交审核",
|
||||
|
||||
+15
@@ -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);
|
||||
}
|
||||
|
||||
+2
-1
@@ -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) {}
|
||||
|
||||
+11
@@ -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) {}
|
||||
|
||||
+31
-10
@@ -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 "
|
||||
|
||||
+2
-2
@@ -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)) {
|
||||
|
||||
+1
-2
@@ -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,
|
||||
|
||||
+44
@@ -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
-2
@@ -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,
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+12
-2
@@ -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);
|
||||
}
|
||||
|
||||
+14
-14
@@ -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(
|
||||
|
||||
+6
@@ -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)
|
||||
|
||||
+18
-6
@@ -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);
|
||||
|
||||
+64
-20
@@ -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)
|
||||
|
||||
+8
@@ -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)
|
||||
|
||||
+95
@@ -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();
|
||||
}
|
||||
}
|
||||
+29
-7
@@ -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());
|
||||
|
||||
+21
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 人工补录会整批替换可编辑测量项,但不能抹掉同一项已冻结的扫码身份与设备取证。
|
||||
* 只按入库货物明细和明细号精确匹配,避免把一个箱码猜测性地复制给其他测量项。
|
||||
|
||||
+11
-1
@@ -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>
|
||||
""")
|
||||
|
||||
+4
-2
@@ -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) {
|
||||
|
||||
+18
-16
@@ -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:
|
||||
|
||||
+33
@@ -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");
|
||||
}
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+16
@@ -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(
|
||||
|
||||
+3
@@ -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:")
|
||||
|
||||
+1
-1
@@ -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");
|
||||
}
|
||||
|
||||
+3
@@ -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'");
|
||||
}
|
||||
|
||||
+39
@@ -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);
|
||||
|
||||
-4
@@ -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
|
||||
|
||||
+18
@@ -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();
|
||||
|
||||
-2
@@ -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]")
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+18
@@ -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();
|
||||
|
||||
+20
-30
@@ -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));
|
||||
|
||||
|
||||
+24
-2
@@ -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());
|
||||
|
||||
|
||||
+3
-3
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+76
@@ -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");
|
||||
}
|
||||
}
|
||||
+25
-6
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+34
@@ -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(
|
||||
|
||||
+112
-9
@@ -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();
|
||||
|
||||
+16
-1
@@ -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();
|
||||
|
||||
+1
-1
@@ -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");
|
||||
}
|
||||
|
||||
+31
-2
@@ -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
|
||||
|
||||
+24
-11
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user