Merge test fixes into dev dropship flow

This commit is contained in:
2026-09-10 18:18:41 +08:00
131 changed files with 3062 additions and 651 deletions
@@ -41,7 +41,7 @@ public class InternalCustomerDimensionConfigController {
@Valid @RequestBody InitializeRequest request) {
requireToken(token);
return ApiResponse.success(service.initializeDefault(
request.tenantId(), request.operatorId(), request.initializationKey()));
request.tenantId(), request.operatorId(), request.initializationKey(), request.customerName()));
}
@DeleteMapping("/defaults")
@@ -92,7 +92,6 @@ public class PlatformContainerController {
@PathVariable @Positive long id,
@PathVariable @Positive long fileId) {
FirstlegScopeGuard.platform(user);
service.requireDomestic(id);
var value = overview.downloadPhoto(id, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.inline()
@@ -93,6 +93,40 @@ public class PlatformInboundMeasurementController {
return ApiResponse.success(service.saveDraft(tenantId, inboundId, user.userId(), request));
}
@BusinessAudit(event = "INBOUND_MEASUREMENT_CALCULATION_STRATEGY_UPDATE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "更新入库测量箱规计算规则")
@Operation(summary = "更新箱规计算规则",
description = "独立更新当前测量批次全部明细的箱规计算规则并重算体积;不改变测量提交或审核状态。"
+ "仅当客户已确认费用且该票已装柜完成时冻结。")
@PutMapping("/measurement/calculation-strategy")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')")
public ApiResponse<InboundMeasurementResponses.CalculationStrategyUpdated> updateCalculationStrategy(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@Valid @RequestBody InboundMeasurementRequests.UpdateCalculationStrategyRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.updateCalculationStrategy(tenantId, inboundId, request));
}
@BusinessAudit(event = "INBOUND_MEASUREMENT_OUTER_DIMENSIONS_UPDATE",
resourceType = "INBOUND_MEASUREMENT_ITEM", resourceId = "#itemId",
description = "更新仓库外箱长宽高")
@Operation(summary = "更新仓库外箱长宽高",
description = "独立更新当前测量批次指定明细的仓库外箱尺寸并重算体积;不改变测量提交或审核状态。"
+ "仅当客户已确认费用且该票已装柜完成时冻结。")
@PutMapping("/measurement/items/{itemId}/outer-dimensions")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')")
public ApiResponse<InboundMeasurementResponses.OuterDimensionsUpdated> updateOuterDimensions(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@PathVariable long itemId,
@Valid @RequestBody InboundMeasurementRequests.UpdateOuterDimensionsRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.updateOuterDimensions(tenantId, inboundId, itemId, request));
}
@BusinessAudit(event = "INBOUND_MEASUREMENT_SUBMIT", resourceType = "INBOUND",
resourceId = "#inboundId", description = "确认测量并提交审核")
@Operation(summary = "确认测量并提交审核",
@@ -10,6 +10,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundOrderDetail;
import com.baiyue.firstleg.dto.InboundOrderQuery;
import com.baiyue.firstleg.dto.InboundOrderSummary;
import com.baiyue.firstleg.dto.InboundCartonLabels;
import com.baiyue.firstleg.dto.InboundHoldDtos.CommandRequest;
import com.baiyue.firstleg.dto.InboundHoldDtos.Detail;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
@@ -22,6 +23,7 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
@@ -29,6 +31,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RequestParam;
@@ -160,6 +163,18 @@ public class PlatformInboundOrderController {
return cartonLabelPrintService.generate(tenantId, inboundId, cartonIndex, requestId);
}
@Operation(summary = "批量生成入仓箱唛 PDF",
description = "按所选客户和入库单聚合生成一个 PDF;首次调用冻结各入库单整单箱号。")
@PostMapping("/carton-label-pdf")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:view')")
public ResponseEntity<byte[]> printCartonLabelsBatch(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestBody @Size(min = 1, max = 200) List<InboundCartonLabels.PlatformBatchItem> items,
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
requirePlatform(user);
return cartonLabelPrintService.generateForPlatformInbounds(items, requestId);
}
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
FirstlegScopeGuard.platform(user, tenantId);
}
@@ -22,7 +22,8 @@ public final class ContainerCustomsDtos {
boolean frozen, FileView completion) {}
public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId,
String planNo, String cabinetNo, String containerNo, String status, String merchantStatus,
String planNo, String cabinetNo, String containerNo, String status, boolean hasActualCargo,
String merchantStatus,
int version, int taxRefundCount, int coveredTaxRefundCount,
List<InboundView> inbounds, List<DocumentView> documents,
LocalDateTime startedAt, LocalDateTime completedAt) {}
@@ -34,6 +34,15 @@ public interface ContainerCustomsMapper {
""")
List<InboundRow> planInbounds(@Param("planId") long planId);
@Select("""
SELECT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId,
p.plan_status loadingPlanStatus,b.cabinet_no cabinetNo,b.container_no containerNo
FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
WHERE p.id=#{planId}
""")
PlanRow plan(@Param("planId") long planId);
@Select("""
SELECT p.id FROM fl_loading_plan p
JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0
@@ -331,6 +340,8 @@ public interface ContainerCustomsMapper {
record InboundRow(Long loadingPlanId,String planNo,Long containerBookingId,String loadingPlanStatus,
String cabinetNo,String containerNo,Long inboundId,Long orderId,String orderNo,String inboundCode,
Long tenantId,String customerName,String taxMode) {}
record PlanRow(Long loadingPlanId,String planNo,Long containerBookingId,String loadingPlanStatus,
String cabinetNo,String containerNo) {}
record ContainerRow(Long id,Long loadingPlanId,Long containerBookingId,String status,Long startedBy,
String startedByName,LocalDateTime startedAt,Long completedBy,String completedByName,
LocalDateTime completedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) {}
@@ -44,6 +44,9 @@ public class ContainerCustomsService {
@Transactional
public ContainerView platformView(long planId) {
List<InboundRow> rows = actualPlanRows(planId);
PlanRow plan = rows.isEmpty() ? requiredPlan(planId) : plan(rows.get(0));
if (!"COMPLETED".equals(plan.loadingPlanStatus())) return null;
ContainerRow container = ensure(planId);
return view(container, null, false);
}
@@ -155,6 +158,7 @@ public class ContainerCustomsService {
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return platformView(planId);
ensure(planId);
if (actualPlanRows(planId).isEmpty()) throw conflict("当前装柜计划没有实际货物,无需报关");
ContainerRow locked = mapper.lockContainer(planId);
if (locked == null || !READY.equals(locked.status())) throw conflict("当前柜报关资料尚未齐全或已开始报关");
if (!Objects.equals(locked.version(), request.expectedVersion())) throw concurrent();
@@ -219,7 +223,8 @@ public class ContainerCustomsService {
ContainerRow container = mapper.lockContainer(planId);
if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中");
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
List<InboundRow> actual=planRows(planId);
List<InboundRow> actual=actualPlanRows(planId);
if(actual.isEmpty())throw conflict("当前装柜计划没有实际货物,无需报关");
if(actual.stream().anyMatch(row->!"BUY_DECLARATION".equals(row.taxMode())))throw conflict("全买单柜完成只允许全部实际行均为BUY_DECLARATION");
completeContainer(container, operatorId, operatorName, request.remark());
completeCommand(operatorId, action, key, container.id());
@@ -245,11 +250,11 @@ public class ContainerCustomsService {
}
private ContainerRow ensure(long planId) {
List<InboundRow> rows = planRows(planId);
InboundRow first = rows.get(0);
if (!"COMPLETED".equals(first.loadingPlanStatus())) throw conflict("装柜完成后才能处理报关资料");
List<InboundRow> rows = actualPlanRows(planId);
PlanRow plan = rows.isEmpty() ? requiredPlan(planId) : plan(rows.get(0));
if (!"COMPLETED".equals(plan.loadingPlanStatus())) throw conflict("装柜完成后才能处理报关资料");
boolean refund = rows.stream().anyMatch(row -> "TAX_REFUND".equals(row.taxMode()));
mapper.insertContainer(IdWorker.getId(), planId, first.containerBookingId(), refund ? WAITING : READY);
mapper.insertContainer(IdWorker.getId(), planId, plan.containerBookingId(), refund ? WAITING : READY);
return refresh(planId);
}
@@ -283,11 +288,27 @@ public class ContainerCustomsService {
}
private List<InboundRow> planRows(long planId) {
List<InboundRow> rows = mapper.planInbounds(planId);
List<InboundRow> rows = actualPlanRows(planId);
if (rows == null || rows.isEmpty()) throw notFound("未找到当前装柜计划的实际货物明细");
return rows;
}
private List<InboundRow> actualPlanRows(long planId) {
List<InboundRow> rows = mapper.planInbounds(planId);
return rows == null ? List.of() : rows;
}
private PlanRow requiredPlan(long planId) {
PlanRow plan = mapper.plan(planId);
if (plan == null) throw notFound("装柜计划不存在或不可用");
return plan;
}
private PlanRow plan(InboundRow row) {
return new PlanRow(row.loadingPlanId(), row.planNo(), row.containerBookingId(), row.loadingPlanStatus(),
row.cabinetNo(), row.containerNo());
}
private List<InboundRow> validateScope(long planId, long tenantId, List<Long> inboundIds) {
if (inboundIds == null || inboundIds.isEmpty() || inboundIds.size() > 200) throw bad("至少选择一个且最多200个入仓码");
Set<Long> requested = new LinkedHashSet<>(inboundIds);
@@ -304,7 +325,8 @@ public class ContainerCustomsService {
}
private ContainerView view(ContainerRow container, Long tenantId, boolean merchant) {
List<InboundRow> rows = planRows(container.loadingPlanId());
List<InboundRow> rows = actualPlanRows(container.loadingPlanId());
PlanRow plan = rows.isEmpty() ? requiredPlan(container.loadingPlanId()) : plan(rows.get(0));
List<DocumentRow> documents = mapper.documents(container.id(), tenantId);
Set<Long> covered = documents.stream().flatMap(doc -> mapper.scopes(doc.id()).stream()).map(ScopeRow::inboundId).collect(Collectors.toSet());
List<InboundRow> visibleRows=rows.stream().filter(row -> tenantId == null || (row.tenantId().equals(tenantId)&&"TAX_REFUND".equals(row.taxMode()))).toList();
@@ -315,9 +337,8 @@ public class ContainerCustomsService {
int refund = (int) visibleRows.stream().filter(row -> "TAX_REFUND".equals(row.taxMode())).count();
int coveredRefund = (int) visibleRows.stream().filter(row -> covered.contains(row.inboundId())).count();
String merchantStatus=tenantId==null?null:merchantStatus(container.status(),refund,coveredRefund);
InboundRow first = rows.get(0);
return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), first.planNo(),
first.cabinetNo(), first.containerNo(), container.status(), merchantStatus, container.version(), refund, coveredRefund,
return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), plan.planNo(),
plan.cabinetNo(), plan.containerNo(), container.status(), !rows.isEmpty(), merchantStatus, container.version(), refund, coveredRefund,
inbounds, documentViews, container.startedAt(), container.completedAt());
}
@@ -60,8 +60,10 @@ public final class CustomerDimensionConfigDtos {
public record InitializeRequest(
@NotNull @Min(1) Long tenantId,
@NotNull @Min(0) Long operatorId,
@NotBlank @Size(max = 128) String initializationKey
@NotBlank @Size(max = 128) String initializationKey,
@NotBlank @Size(max = 128) String customerName
) { }
public record InitializeResponse(Long configId, boolean createdByThisInitialization) { }
public record InitializeResponse(Long configId, Long chargeRuleId,
boolean createdByThisInitialization) { }
}
@@ -3,6 +3,8 @@ package com.baiyue.firstleg.dto;
import java.math.BigDecimal;
import java.util.List;
import jakarta.validation.constraints.Min;
/** 稳定箱号分配、补打和测量解析契约。 */
public final class InboundCartonLabels {
private InboundCartonLabels() {
@@ -12,6 +14,9 @@ public final class InboundCartonLabels {
List<Label> labels) {
}
public record PlatformBatchItem(@Min(1) long tenantId, @Min(1) long inboundId) {
}
public record Label(Long id, Long inboundDetailId, Integer cargoItemNo, String cargoName,
Integer detailPackageCount, String boxCode, Integer totalCount,
Integer cartonIndex, String boxSpecification, BigDecimal lengthCm,
@@ -87,6 +87,26 @@ public final class InboundMeasurementRequests {
) {
}
public record UpdateCalculationStrategyRequest(
@NotNull @Positive Long measurementId,
@NotNull @PositiveOrZero Integer version,
@NotBlank @jakarta.validation.constraints.Pattern(regexp = "ACTUAL|ADD|MULTIPLY") String coefficientMode,
@Positive Long chargeRuleId
) {
}
public record UpdateOuterDimensionsRequest(
@NotNull @Positive Long measurementId,
@NotNull @PositiveOrZero Integer version,
@DecimalMin("0.001") @DecimalMax("999999999.999")
@Digits(integer = 9, fraction = 3) BigDecimal outerLengthCm,
@DecimalMin("0.001") @DecimalMax("999999999.999")
@Digits(integer = 9, fraction = 3) BigDecimal outerWidthCm,
@DecimalMin("0.001") @DecimalMax("999999999.999")
@Digits(integer = 9, fraction = 3) BigDecimal outerHeightCm
) {
}
public record ConfirmedPackageCountRequest(
@NotNull @PositiveOrZero Integer measurementVersion,
@NotNull @Positive @Max(1000000) Integer confirmedPackageCount
@@ -183,6 +183,16 @@ public final class InboundMeasurementResponses {
public record DraftSaved(Long measurementId, Integer version, String status) {
}
public record CalculationStrategyUpdated(
Long measurementId, Integer version, String status, String coefficientMode, Long chargeRuleId
) {
}
public record OuterDimensionsUpdated(
Long measurementId, Integer version, String status, Long itemId
) {
}
public record MeasurementSubmitted(Long measurementId, Integer version, String processStatus,
String measureReviewStatus,
@Schema(description = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘",
@@ -38,4 +38,6 @@ public class CustomerChargeRuleEntity extends BaseEntity {
private LocalDateTime effectiveTo;
private String status;
private String remark;
/** 客户创建执行器的幂等键;人工创建的规则为空。 */
private String initializationKey;
}
@@ -15,7 +15,6 @@ import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.CompletionTarget;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.loading.repository.LoadingRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.service.FbaPricingService;
import com.baiyue.firstleg.service.InboundHoldGuard;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -42,7 +41,6 @@ public class LoadingDeliveryService {
@Resource private LoadingCompletionGateService completionGate;
@Resource private InboundStatusHistoryMapper histories;
@Resource private CabinetLoadingEvidenceService cabinetEvidenceService;
@Resource private FbaPricingService pricingService;
@Resource private ObjectMapper objectMapper;
@Resource private InboundHoldGuard inboundHoldGuard;
@@ -69,7 +67,6 @@ public class LoadingDeliveryService {
}
if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) {
requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion);
pricingService.autoConfirmAfterLoading(tenantId, inboundId);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse(inbound.processStatus()));
}
@@ -80,7 +77,6 @@ public class LoadingDeliveryService {
requireCabinetEvidence(tenantId, inboundId);
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName);
pricingService.autoConfirmAfterLoading(tenantId, inboundId);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED"));
}
@@ -124,7 +120,6 @@ public class LoadingDeliveryService {
CompletionTarget target,
boolean acceptedPdaCompletion) {
if ("LOADED".equals(target.loadedChildStatus())) {
pricingService.autoConfirmAfterLoading(tenantId, target.completionInboundId());
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED"));
}
@@ -140,7 +135,6 @@ public class LoadingDeliveryService {
repository.aggregateRootLoaded(tenantId, target.rootInboundId(), operatorId);
addHistory(tenantId, target.completionInboundId(), "LOADING", "LOADED", COMPLETE_LOADING,
request.remark(), operatorId, operatorName);
pricingService.autoConfirmAfterLoading(tenantId, target.completionInboundId());
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED"));
}
@@ -1,7 +1,50 @@
package com.baiyue.firstleg.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
@Mapper
public interface CustomerChargeRuleMapper extends BaseMapper<CustomerChargeRuleEntity> {}
public interface CustomerChargeRuleMapper extends BaseMapper<CustomerChargeRuleEntity> {
@Select("""
SELECT id, tenant_id, rule_code, rule_version, customer_id, customer_name, business_type,
charge_mode, tax_mode, conversion_factor, length_factor, width_factor, height_factor,
default_rule, rounding_scale, rounding_mode, currency, effective_from, effective_to,
status, remark, initialization_key, created_by, created_at, updated_by, updated_at,
deleted, version
FROM fl_customer_charge_rule
WHERE tenant_id = #{tenantId} AND initialization_key = #{initializationKey} AND deleted = 0
LIMIT 1
""")
CustomerChargeRuleEntity selectByInitializationKey(
@Param("tenantId") long tenantId, @Param("initializationKey") String initializationKey);
@Select("""
SELECT id, tenant_id, rule_code, rule_version, customer_id, customer_name, business_type,
charge_mode, tax_mode, conversion_factor, length_factor, width_factor, height_factor,
default_rule, rounding_scale, rounding_mode, currency, effective_from, effective_to,
status, remark, initialization_key, created_by, created_at, updated_by, updated_at,
deleted, version
FROM fl_customer_charge_rule
WHERE tenant_id = #{tenantId} AND initialization_key = #{initializationKey} AND deleted = 0
LIMIT 1 FOR UPDATE
""")
CustomerChargeRuleEntity selectByInitializationKeyForUpdate(
@Param("tenantId") long tenantId, @Param("initializationKey") String initializationKey);
@Delete("""
DELETE FROM fl_customer_charge_rule
WHERE tenant_id = #{tenantId} AND initialization_key = #{initializationKey}
AND rule_code = 'DEFAULT-DIMENSION-WEIGHT' AND rule_version = 1
AND customer_id = #{tenantId} AND business_type = 'FBA' AND charge_mode = 'CBM'
AND tax_mode = 'TAX_INCLUDED' AND conversion_factor = 167.000000
AND length_factor = 1.100000 AND width_factor = 1.100000 AND height_factor = 1.100000
AND default_rule = 1 AND rounding_scale = 2 AND rounding_mode = 'HALF_UP'
AND currency = 'CNY' AND effective_to IS NULL AND status = 'ACTIVE'
AND deleted = 0 AND version = 0
""")
int deleteUnchangedInitializedRule(
@Param("tenantId") long tenantId, @Param("initializationKey") String initializationKey);
}
@@ -150,15 +150,13 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
AND plan.id = pick_task.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
JOIN fl_inbound_order inbound_order ON inbound_order.id = plan_item.inbound_id
AND inbound_order.tenant_id = plan_item.account_id AND inbound_order.deleted = 0
WHERE pick_item.pick_task_id = #{taskId} AND booking.warehouse_id = #{warehouseId}
AND plan_item.deleted = 0
WHERE pick_item.pick_task_id = #{taskId} AND plan_item.deleted = 0
ORDER BY pick_item.id ASC
""")
java.util.List<LoadingPickItemEntity> selectTaskItemsWithAccount(@Param("taskId") long taskId,
@Param("warehouseId") long warehouseId);
@Param("warehouseId") long warehouseId);
@Select("""
SELECT pick_item.*, plan_item.unit_type, plan_item.pallet_id, plan_item.pallet_no, plan_item.indivisible
@@ -167,11 +165,10 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
AND plan.id = pick_task.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
JOIN fl_inbound_order inbound_order ON inbound_order.id = plan_item.inbound_id
AND inbound_order.tenant_id = plan_item.account_id AND inbound_order.deleted = 0
WHERE pick_item.pick_task_id = #{taskId} AND pick_item.id = #{itemId}
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0
AND plan_item.deleted = 0
""")
LoadingPickItemEntity selectTaskItemWithAccount(@Param("taskId") long taskId,
@Param("itemId") long itemId,
@@ -185,10 +182,8 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
version = version + 1
WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
AND plan_item.deleted = 0)
AND picked_package_count + #{count} <= planned_package_count
""")
int pick(@Param("id") long id, @Param("count") int count,
@@ -201,10 +196,8 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
version = version + 1
WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
AND plan_item.deleted = 0)
AND item_status IN ('PENDING', 'PICKING')
AND picked_package_count < #{finalCount}
AND #{finalCount} < planned_package_count
@@ -218,13 +211,12 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
SET item_status = 'SKIPPED', version = version + 1
WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
AND plan_item.deleted = 0)
AND item_status = 'PENDING' AND picked_package_count = 0
""")
int skip(@Param("id") long id, @Param("version") int version, @Param("warehouseId") long warehouseId);
int skip(@Param("id") long id, @Param("version") int version,
@Param("warehouseId") long warehouseId);
@Select("""
SELECT COUNT(*)
@@ -50,7 +50,6 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE booking.container_no = #{containerNo} AND booking.record_status = 'ACTIVE'
AND task.task_status = 'LOADING'
AND booking.warehouse_id = #{warehouseId}
LIMIT 1
""")
LoadingPickTaskEntity selectLoadingByContainerAndAccount(@Param("containerNo") String containerNo,
@@ -59,13 +58,13 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
@Select("SELECT task.* FROM fl_loading_pick_task task JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id "
+ "JOIN fl_container_booking booking ON booking.id=plan.container_booking_id "
+ "WHERE task.task_status='LOADING' AND booking.record_status='ACTIVE' "
+ "AND booking.cabinet_no=#{cabinetNo} AND booking.warehouse_id=#{warehouseId} LIMIT 1")
+ "AND booking.cabinet_no=#{cabinetNo} LIMIT 1")
LoadingPickTaskEntity selectLoadingByCabinet(@Param("cabinetNo") String cabinetNo, @Param("warehouseId") long warehouseId);
@Select("SELECT task.* FROM fl_loading_pick_task task JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id "
+ "JOIN fl_container_booking booking ON booking.id=plan.container_booking_id "
+ "WHERE task.task_status='LOADING' AND booking.record_status='ACTIVE' "
+ "AND task.loading_plan_id=#{loadingPlanId} AND booking.warehouse_id=#{warehouseId} LIMIT 1")
+ "AND task.loading_plan_id=#{loadingPlanId} LIMIT 1")
LoadingPickTaskEntity selectLoadingByPlan(@Param("loadingPlanId") long loadingPlanId, @Param("warehouseId") long warehouseId);
@Select("SELECT booking.* FROM fl_loading_pick_task task "
@@ -97,7 +97,7 @@ public class OutboundDispatchService {
OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId);
if (carrierSchedules != null) {
if (ocean == null || ocean.id() == null || ocean.customerId() == null) {
if (ocean == null || ocean.id() == null) {
throw conflict("当前货柜未关联有效订舱,无法创建船司查询任务");
}
carrierSchedules.ensureFromOutboundConfirm(ocean.customerId(), ocean.id(), containerBookingId,
@@ -129,7 +129,7 @@ public class OutboundDispatchService {
if (!Objects.equals(visible.version(), request.expectedVersion())) throw concurrent();
OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId);
if (carrierSchedules != null) {
if (ocean == null || ocean.id() == null || ocean.customerId() == null) {
if (ocean == null || ocean.id() == null) {
throw conflict("无法核验货柜后续船节点,禁止撤销国内出仓");
}
if (carrierSchedules.hasOutboundDepartureFact(ocean.customerId(), ocean.id(), containerBookingId)) {
@@ -111,8 +111,7 @@ public class PlatformCarrierScheduleQueryController {
@PostMapping("/{queryId}/manual-nodes")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
@Operation(summary = "人工补录预计或实际标准船节点",
description = "预计节点不推进实际进度;人工实际节点必须严格晚于当前最新实际节点,同级或更早节点返回 A0409。")
@Operation(summary = "在跟踪任务内人工补录预计或实际标准船节点")
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY",
resourceId = "#queryId", description = "人工补录船节点事实")
public ApiResponse<Detail> manualNode(@AuthenticationPrincipal AuthenticatedUser user,
@@ -0,0 +1,44 @@
package com.baiyue.firstleg.portschedule.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerNodeDetail;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@Validated
@RestController
@RequestMapping("/api/platform/v1/container-shipping-nodes")
public class PlatformContainerShippingNodeController {
@Resource private CarrierScheduleQueryService service;
@GetMapping("/{containerBookingId}")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')")
public ApiResponse<ContainerNodeDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.containerNodeDetail(containerBookingId));
}
@PostMapping("/{containerBookingId}")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
@BusinessAudit(event = "CONTAINER_SHIPPING_NODE_MANUAL", resourceType = "CONTAINER_BOOKING",
resourceId = "#containerBookingId", description = "人工修改货柜当前船节点")
public ApiResponse<ContainerNodeDetail> add(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ContainerManualNodeRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.addContainerManualNode(containerBookingId, user.userId(),
user.username(), key, request));
}
}
@@ -13,7 +13,7 @@ import java.util.List;
public final class CarrierScheduleQueryDtos {
private CarrierScheduleQueryDtos() {}
public record Query(@Positive Long customerId, @Positive Long oceanBookingId,
public record Query(@Min(0) Long customerId, @Positive Long oceanBookingId,
@Positive Long containerBookingId, @Size(max = 16) String carrierCode,
@Size(max = 24) String status, Boolean expired,
@Size(max = 128) String keyword, @Min(1) Long page,
@@ -22,7 +22,7 @@ public final class CarrierScheduleQueryDtos {
}
public record CreateRequest(@NotNull @Min(0) @Max(0) Integer expectedVersion,
@NotNull @Positive Long customerId,
@NotNull @Min(0) Long customerId,
@NotNull @Positive Long oceanBookingId,
@NotNull @Positive Long containerBookingId,
@NotBlank @Size(max = 16) String carrierCode,
@@ -43,6 +43,14 @@ public final class CarrierScheduleQueryDtos {
@Size(max = 128) String locationName,
@NotBlank @Size(max = 1000) String reason) {}
public record ContainerManualNodeRequest(@NotBlank @Size(max = 32) String standardNode,
@NotNull LocalDateTime observedAt,
LocalDateTime estimatedAt,
LocalDateTime actualAt,
@Size(max = 32) String locationCode,
@Size(max = 128) String locationName,
@NotBlank @Size(max = 1000) String reason) {}
public record ProviderObservationRequest(@NotBlank @Size(max = 32) String providerCode,
@NotBlank @Size(max = 128) String providerEventId,
@NotBlank @Size(max = 64) String providerNodeCode,
@@ -97,6 +105,9 @@ public final class CarrierScheduleQueryDtos {
LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {}
public record ContainerNodeDetail(Long containerBookingId, String currentNode,
LocalDateTime currentNodeAt, List<NodeFact> nodes) {}
public record ExceptionFact(Long id, String providerCode, String providerEventId,
String providerStatus, String exceptionCode, boolean active,
LocalDateTime observedAt, String locationCode, String locationName,
@@ -16,6 +16,7 @@ import java.time.LocalDateTime;
public class CarrierScheduleNodeFactEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long queryId;
private Long containerBookingId;
private String eventKey;
private String providerCode;
private String providerEventId;
@@ -5,19 +5,29 @@ import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
@Insert("""
INSERT INTO fl_carrier_schedule_node_fact
(id,query_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at,
(id,query_id,container_booking_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at,
estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at)
VALUES
(#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode},
(#{value.id},#{value.queryId},#{value.containerBookingId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode},
#{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt},
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},
CURRENT_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE id=id
""")
int insertIgnore(@Param("value") CarrierScheduleNodeFactEntity value);
@Select("SELECT * FROM fl_carrier_schedule_node_fact WHERE container_booking_id=#{containerBookingId} ORDER BY observed_at,id")
List<CarrierScheduleNodeFactEntity> selectByContainer(@Param("containerBookingId") long containerBookingId);
@Select("SELECT * FROM fl_carrier_schedule_node_fact WHERE container_booking_id=#{containerBookingId} AND event_key=#{eventKey} LIMIT 1")
CarrierScheduleNodeFactEntity selectByContainerEvent(@Param("containerBookingId") long containerBookingId,
@Param("eventKey") String eventKey);
}
@@ -49,26 +49,26 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
@Select("""
<script>
SELECT q.container_booking_id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
q.status AS queryStatus,q.latest_standard_node AS latestActualNode,
q.latest_node_observed_at AS latestActualAt,
SELECT b.id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
q.status AS queryStatus,current.standard_node AS latestActualNode,
current.actual_at AS latestActualAt,
MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE' THEN f.actual_at END) AS departedWarehouseAt,
MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) AS departedOriginPortAt,
MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT' THEN f.actual_at END) AS arrivedDestinationPortAt,
MAX(CASE WHEN f.standard_node='PICKUP_READY' THEN f.actual_at END) AS pickupReadyAt,
MAX(CASE WHEN f.standard_node='PICKED_UP' THEN f.actual_at END) AS pickedUpAt,
MAX(CASE WHEN f.standard_node='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt
FROM fl_carrier_schedule_query q
JOIN (
SELECT container_booking_id,MAX(id) AS query_id
FROM fl_carrier_schedule_query
WHERE container_booking_id IN
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
GROUP BY container_booking_id
) latest ON latest.query_id=q.id
LEFT JOIN fl_carrier_schedule_node_fact f ON f.query_id=q.id AND f.actual_at IS NOT NULL
GROUP BY q.container_booking_id,q.id,q.version,q.status,q.latest_standard_node,q.latest_node_observed_at
ORDER BY q.container_booking_id
FROM fl_container_booking b
JOIN fl_carrier_schedule_node_fact current ON current.id=(
SELECT f2.id FROM fl_carrier_schedule_node_fact f2
WHERE f2.container_booking_id=b.id AND f2.actual_at IS NOT NULL
ORDER BY f2.observed_at DESC,f2.id DESC LIMIT 1)
LEFT JOIN fl_carrier_schedule_query q ON q.id=current.query_id
LEFT JOIN fl_carrier_schedule_node_fact f ON f.container_booking_id=b.id AND f.actual_at IS NOT NULL
WHERE b.id IN
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
GROUP BY b.id,q.id,q.version,q.status,current.standard_node,current.actual_at
ORDER BY b.id
</script>
""")
List<ContainerActualFacts> selectContainerActualFacts(
@@ -79,6 +79,12 @@ public class CarrierScheduleQueryRepository {
.eq(CarrierScheduleNodeFactEntity::getQueryId, queryId)
.eq(CarrierScheduleNodeFactEntity::getEventKey, eventKey));
}
public List<CarrierScheduleNodeFactEntity> nodesByContainer(long containerBookingId) {
return nodes.selectByContainer(containerBookingId);
}
public CarrierScheduleNodeFactEntity nodeByContainer(long containerBookingId, String eventKey) {
return nodes.selectByContainerEvent(containerBookingId, eventKey);
}
public boolean hasActualNode(long queryId, String standardNode) {
return nodes.selectCount(new LambdaQueryWrapper<CarrierScheduleNodeFactEntity>()
.eq(CarrierScheduleNodeFactEntity::getQueryId, queryId)
@@ -42,28 +42,40 @@ public class CarrierScheduleNodeOutboxService {
|| !normalizedContainerNo.equals(query.getTrackingNo())) {
throw new BizException(ResultCode.CONFLICT, "船节点任务跟踪柜号与跨区事件柜号不一致");
}
publish(query.getId(), query.getOceanBookingId(), query.getContainerBookingId(), query.getVersion(),
fact, normalizedContainerNo, "CARRIER_NODE:" + query.getId() + ':' + fact.getId());
}
public void publishContainerNode(Long oceanBookingId, long containerBookingId,
CarrierScheduleNodeFactEntity fact, String containerNo) {
if (fact.getActualAt() == null) return;
String normalizedContainerNo = ContainerNumbers.normalizeRequired(containerNo);
publish(null, oceanBookingId, containerBookingId, 0, fact, normalizedContainerNo,
"CONTAINER_NODE:" + containerBookingId + ':' + fact.getId());
}
private void publish(Long queryId, Long oceanBookingId, Long containerBookingId, Integer version,
CarrierScheduleNodeFactEntity fact, String normalizedContainerNo, String eventKey) {
ZoneId zone = ZoneId.of(scheduleZone);
var payload = new FirstLegContainerShippingNodeRecordedV1(
string(query.getId()), string(query.getOceanBookingId()),
string(query.getContainerBookingId()), normalizedContainerNo,
string(queryId), string(oceanBookingId), string(containerBookingId), normalizedContainerNo,
fact.getStandardNode(), fact.getNodeSequence(),
fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(),
fact.getLocationCode(), fact.getLocationName(), fact.getProviderCode(), fact.getProviderEventId(),
fact.getProviderNodeCode(), fact.getSourceReference(), fact.getRawPayloadHash());
JsonNode payloadNode = objectMapper.valueToTree(payload);
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
long aggregateVersion = Math.max(0, query.getVersion() == null ? 0 : query.getVersion());
String eventKey = "CARRIER_NODE:" + query.getId() + ':' + fact.getId();
long aggregateVersion = Math.max(0, version == null ? 0 : version);
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1,
fact.getActualAt().atZone(zone).toInstant(), RegionCode.CN, RegionCode.OVERSEAS,
"0", "CONTAINER_BOOKING", string(query.getContainerBookingId()),
"0", "CONTAINER_BOOKING", string(containerBookingId),
aggregateVersion, aggregateVersion + 1, EventIdentifiers.newUuid(), eventKey, null,
payloadNode, payloadHash).requireValid();
InboundOutboxEntity row = new InboundOutboxEntity();
row.setId(IdWorker.getId()); row.setTenantId(0L);
row.setAggregateType(envelope.aggregateType()); row.setAggregateId(query.getContainerBookingId());
row.setAggregateType(envelope.aggregateType()); row.setAggregateId(containerBookingId);
row.setEventType(envelope.eventType()); row.setEventKey(eventKey); row.setEventVersion(1);
row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash);
row.setPayloadJson(json(envelope)); row.setPublishStatus("PENDING"); row.setRetryCount(0);
@@ -8,6 +8,8 @@ import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerNodeDetail;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
@@ -48,6 +50,7 @@ import java.security.MessageDigest;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.HashSet;
import java.util.List;
@@ -101,15 +104,17 @@ public class CarrierScheduleQueryService {
/** Idempotently starts the canonical tracking task after domestic outbound confirmation. */
@Transactional
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(long customerId, long oceanBookingId,
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId,
long containerBookingId,
long operatorId, String operatorName) {
CarrierScheduleQueryEntity existing = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId);
OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId);
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在");
validateLinks(customerId, oceanBookingId, containerBookingId);
long scopeCustomerId = booking.getCustomerId() == null ? 0L : booking.getCustomerId();
if (customerId != null && customerId != scopeCustomerId) throw badRequest("customerId与订舱客户不一致");
CarrierScheduleQueryEntity existing = repository.findByBusinessScope(scopeCustomerId, oceanBookingId,
containerBookingId);
validateLinks(scopeCustomerId, oceanBookingId, containerBookingId);
String trackingType = "CONTAINER_NO";
String trackingNo = normalizedContainerNo(container);
if (existing != null) {
@@ -120,7 +125,7 @@ public class CarrierScheduleQueryService {
LocalDateTime now = now();
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId());
value.setCustomerId(customerId); value.setOceanBookingId(oceanBookingId);
value.setCustomerId(scopeCustomerId); value.setOceanBookingId(oceanBookingId);
value.setContainerBookingId(containerBookingId); value.setCarrierCode(strategy.carrierCode());
value.setTrackingType(trackingType); value.setTrackingNo(trackingNo);
value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode());
@@ -136,7 +141,7 @@ public class CarrierScheduleQueryService {
try {
try { repository.insert(value); }
catch (DuplicateKeyException exception) {
CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId,
CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(scopeCustomerId, oceanBookingId,
containerBookingId);
if (concurrent != null) {
requireFrozenTrackingIdentity(concurrent, trackingNo);
@@ -145,7 +150,7 @@ public class CarrierScheduleQueryService {
throw exception;
}
persistenceStage = "NODE_FACT";
CarrierScheduleNodeFactEntity departed = addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
CarrierScheduleNodeFactEntity departed = addNode(value, "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
sha256("OUTBOUND_CONFIRM|" + containerBookingId));
if (departed != null) {
@@ -176,7 +181,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleQueryEntity value = requiredLocked(id);
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
CarrierScheduleQueryEntity before = copy(value);
CarrierScheduleNodeFactEntity fact = addNode(id, request.providerCode(), request.providerEventId(),
CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(),
request.providerNodeCode(), node,
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
request.locationName(), request.sourceReference(), request.rawPayloadHash());
@@ -217,10 +222,9 @@ public class CarrierScheduleQueryService {
}
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
requireManualActualAdvance(value, node, request.actualAt());
CarrierScheduleQueryEntity before = copy(value);
String providerEventId = sha256("MANUAL|" + id + '|' + key);
CarrierScheduleNodeFactEntity fact = addNode(id, "MANUAL", providerEventId, node.name(), node,
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
request.observedAt(),
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
"MANUAL:" + operatorId, hash(operation, id, request));
@@ -239,13 +243,42 @@ public class CarrierScheduleQueryService {
return complete(id, operatorId, key, detail(after));
}
private void requireManualActualAdvance(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
LocalDateTime actualAt) {
if (actualAt == null || value.getLatestStandardNode() == null) return;
CarrierScheduleNode current = CarrierScheduleNode.require(value.getLatestStandardNode());
if (node.sequence() <= current.sequence()) {
throw conflict("人工实际节点必须严格晚于当前最新实际节点");
public ContainerNodeDetail containerNodeDetail(long containerBookingId) {
if (portSchedules.containerBooking(containerBookingId) == null) {
throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
}
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
}
@Transactional
public ContainerNodeDetail addContainerManualNode(long containerBookingId, long operatorId,
String operatorName, String key,
ContainerManualNodeRequest request) {
validateKey(key);
if (request.estimatedAt() == null && request.actualAt() == null) {
throw badRequest("estimatedAt和actualAt至少提供一个");
}
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
if (container == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
String providerEventId = sha256("CONTAINER_MANUAL|" + containerBookingId + '|' + key);
String payloadHash = hash("CONTAINER_MANUAL_NODE", containerBookingId, request);
CarrierScheduleNodeFactEntity fact = addNode(null, containerBookingId, "MANUAL", providerEventId,
node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(),
request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash);
if (fact != null) nodeOutbox.publishContainerNode(container.getOceanBookingId(), containerBookingId,
fact, normalizedContainerNo(container));
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
}
private ContainerNodeDetail containerNodeDetail(long containerBookingId,
List<CarrierScheduleNodeFactEntity> facts) {
CarrierScheduleNodeFactEntity current = facts.stream()
.max(Comparator.comparing(CarrierScheduleNodeFactEntity::getObservedAt)
.thenComparing(CarrierScheduleNodeFactEntity::getId)).orElse(null);
return new ContainerNodeDetail(containerBookingId, current == null ? null : current.getStandardNode(),
current == null ? null : current.getActualAt() != null ? current.getActualAt() : current.getEstimatedAt(),
facts.stream().map(this::node).toList());
}
private boolean advanceActualNode(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
@@ -424,7 +457,8 @@ public class CarrierScheduleQueryService {
private void validateLinks(long customerId, long oceanBookingId, Long containerBookingId) {
OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId);
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "关联订舱不存在");
if (!Objects.equals(booking.getCustomerId(), customerId)) throw badRequest("customerId与订舱客户不一致");
long expectedCustomerId = booking.getCustomerId() == null ? 0L : booking.getCustomerId();
if (expectedCustomerId != customerId) throw badRequest("customerId与订舱客户不一致");
if (containerBookingId != null) {
if (portSchedules.containerBooking(containerBookingId) == null) {
throw new BizException(ResultCode.NOT_FOUND, "关联柜次不存在");
@@ -490,13 +524,23 @@ public class CarrierScheduleQueryService {
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
}
private CarrierScheduleNodeFactEntity addNode(long queryId, String providerCode, String providerEventId,
private CarrierScheduleNodeFactEntity addNode(CarrierScheduleQueryEntity query, String providerCode, String providerEventId,
String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) {
return addNode(query.getId(), query.getContainerBookingId(), providerCode, providerEventId,
providerNodeCode, node, observedAt, estimatedAt, actualAt, locationCode, locationName,
sourceReference, rawPayloadHash);
}
private CarrierScheduleNodeFactEntity addNode(Long queryId, long containerBookingId,
String providerCode, String providerEventId, String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) {
CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity();
fact.setId(IdWorker.getId()); fact.setQueryId(queryId);
fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setContainerBookingId(containerBookingId);
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT));
fact.setProviderEventId(providerEventId.trim());
@@ -505,7 +549,7 @@ public class CarrierScheduleQueryService {
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
if (repository.insertIfAbsent(fact)) return fact;
CarrierScheduleNodeFactEntity existing = repository.node(queryId, fact.getEventKey());
CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey());
if (existing != null && !Objects.equals(existing.getRawPayloadHash(), rawPayloadHash)) {
throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有节点事实");
}
@@ -55,6 +55,19 @@ public class FbaPricingRepository {
public void insertRule(CustomerChargeRuleEntity rule) { rules.insert(rule); }
public CustomerChargeRuleEntity findRuleByInitializationKey(long tenantId, String initializationKey) {
return rules.selectByInitializationKey(tenantId, initializationKey);
}
public CustomerChargeRuleEntity findRuleByInitializationKeyForUpdate(
long tenantId, String initializationKey) {
return rules.selectByInitializationKeyForUpdate(tenantId, initializationKey);
}
public int deleteUnchangedInitializedRule(long tenantId, String initializationKey) {
return rules.deleteUnchangedInitializedRule(tenantId, initializationKey);
}
public int clearDefaultRule(long tenantId, long customerId, long operatorId) {
return rules.update(null, new LambdaUpdateWrapper<CustomerChargeRuleEntity>()
.eq(CustomerChargeRuleEntity::getTenantId, tenantId)
@@ -285,22 +298,22 @@ public class FbaPricingRepository {
.set(InboundFeeEntity::getUpdatedAt, LocalDateTime.now()));
}
public int markInboundAdjusted(long tenantId, long inboundId, String feeStatus,
public int markInboundAdjusted(long tenantId, long inboundId, String processStatus, String feeStatus,
java.math.BigDecimal selectedWeightKg,
java.math.BigDecimal selectedVolumeCbm,
String chargeUnit, java.math.BigDecimal chargeableQuantity) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
.eq(InboundPricingEntity::getId, inboundId)
.eq(InboundPricingEntity::getProcessStatus, "WAIT_SPLIT")
.eq(InboundPricingEntity::getProcessStatus, processStatus)
.set(InboundPricingEntity::getFeeStatus, feeStatus);
if ("KG".equals(chargeUnit)) {
update.set(InboundPricingEntity::getChargeableWeightKg, chargeableQuantity);
} else {
update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeableQuantity);
}
applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeUnit, chargeableQuantity);
return inbounds.update(null, update);
}
public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode,
java.math.BigDecimal selectedWeightKg,
java.math.BigDecimal selectedVolumeCbm,
java.math.BigDecimal chargeable) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
@@ -308,15 +321,17 @@ public class FbaPricingRepository {
.eq(InboundPricingEntity::getProcessStatus, processStatus)
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED", "SENT"))
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
if (!List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING").contains(processStatus)) {
if (!List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")
.contains(processStatus)) {
update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING");
}
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable);
else update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeable);
applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeMode, chargeable);
return inbounds.update(null, update);
}
public int markInboundContainerFeeRecalculated(long tenantId, long inboundId, String processStatus,
java.math.BigDecimal selectedWeightKg,
java.math.BigDecimal selectedVolumeCbm,
String chargeMode, java.math.BigDecimal chargeable) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
@@ -324,11 +339,21 @@ public class FbaPricingRepository {
.eq(InboundPricingEntity::getProcessStatus, processStatus)
.in(InboundPricingEntity::getFeeStatus, List.of("CALCULATED", "CONFIRMED"))
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable);
else update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeable);
applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeMode, chargeable);
return inbounds.update(null, update);
}
private void applyChargeableSnapshot(LambdaUpdateWrapper<InboundPricingEntity> update,
java.math.BigDecimal selectedWeightKg,
java.math.BigDecimal selectedVolumeCbm,
String chargeUnit, java.math.BigDecimal chargeableQuantity) {
// 列表同时展示两项:当前计费单位保存最终计费量,另一项保存本次计价选定值。
update.set(InboundPricingEntity::getChargeableWeightKg,
"KG".equals(chargeUnit) ? chargeableQuantity : selectedWeightKg)
.set(InboundPricingEntity::getChargeableVolumeCbm,
"CBM".equals(chargeUnit) ? chargeableQuantity : selectedVolumeCbm);
}
public int confirmFee(long tenantId, long feeId, long operatorId, LocalDateTime now) {
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId).eq(InboundFeeEntity::getId, feeId)
@@ -71,6 +71,14 @@ public class InboundMeasurementRepository {
.eq(InboundMeasurementEntity::getId, measurementId));
}
public InboundMeasurementEntity lockMeasurement(long tenantId, long inboundId, long measurementId) {
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
.eq(InboundMeasurementEntity::getTenantId, tenantId)
.eq(InboundMeasurementEntity::getInboundId, inboundId)
.eq(InboundMeasurementEntity::getId, measurementId)
.last("FOR UPDATE"));
}
public InboundMeasurementEntity findLatestMeasurement(long tenantId, long inboundId) {
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
.eq(InboundMeasurementEntity::getTenantId, tenantId)
@@ -0,0 +1,95 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 在客户创建执行器中幂等生成“箱规按权重系数计算”的默认规则。 */
@Service
public class CustomerChargeRuleInitializationService {
static final String DEFAULT_RULE_CODE = "DEFAULT-DIMENSION-WEIGHT";
static final BigDecimal DEFAULT_COEFFICIENT = new BigDecimal("1.100000");
static final BigDecimal DEFAULT_CONVERSION_FACTOR = new BigDecimal("167.000000");
static final String DEFAULT_REMARK = "SMALL:30,20,20;系数:1.1;重量:5;小箱:标准小箱;"
+ "MEDIUM:40,30,30;系数:1.1;重量:8;中箱:标准中箱;"
+ "LARGE:50,40,40;系数:1.1;重量:12;大箱:标准大箱;"
+ "EXTRA_LARGE:60,50,50;系数:1.1;重量:15;特大箱:特大箱";
@Resource private FbaPricingRepository repository;
public CustomerChargeRuleEntity initializeDefault(long tenantId, long operatorId,
String initializationKey, String customerName) {
CustomerChargeRuleEntity existing = repository.findRuleByInitializationKey(
tenantId, initializationKey);
if (existing != null) return existing;
CustomerChargeRuleEntity value = new CustomerChargeRuleEntity();
value.setId(IdWorker.getId());
value.setTenantId(tenantId);
value.setRuleCode(DEFAULT_RULE_CODE);
value.setRuleVersion(1);
value.setCustomerId(tenantId);
value.setCustomerName(requireCustomerName(customerName));
value.setBusinessType("FBA");
value.setChargeMode("CBM");
value.setTaxMode("TAX_INCLUDED");
value.setConversionFactor(DEFAULT_CONVERSION_FACTOR);
value.setLengthFactor(DEFAULT_COEFFICIENT);
value.setWidthFactor(DEFAULT_COEFFICIENT);
value.setHeightFactor(DEFAULT_COEFFICIENT);
value.setDefaultRule(true);
value.setRoundingScale(2);
value.setRoundingMode("HALF_UP");
value.setCurrency("CNY");
value.setEffectiveFrom(LocalDateTime.now());
value.setEffectiveTo(null);
value.setStatus("ACTIVE");
value.setRemark(DEFAULT_REMARK);
value.setInitializationKey(initializationKey);
value.setCreatedBy(operatorId);
value.setUpdatedBy(operatorId);
repository.insertRule(value);
return value;
}
CustomerChargeRuleEntity lockForCompensation(long tenantId, String initializationKey) {
return repository.findRuleByInitializationKeyForUpdate(tenantId, initializationKey);
}
boolean isUnchangedDefault(CustomerChargeRuleEntity value) {
return value != null
&& value.getVersion() != null && value.getVersion() == 0
&& DEFAULT_RULE_CODE.equals(value.getRuleCode()) && Integer.valueOf(1).equals(value.getRuleVersion())
&& value.getTenantId().equals(value.getCustomerId())
&& "FBA".equals(value.getBusinessType()) && "CBM".equals(value.getChargeMode())
&& "TAX_INCLUDED".equals(value.getTaxMode())
&& DEFAULT_CONVERSION_FACTOR.compareTo(value.getConversionFactor()) == 0
&& DEFAULT_COEFFICIENT.compareTo(value.getLengthFactor()) == 0
&& DEFAULT_COEFFICIENT.compareTo(value.getWidthFactor()) == 0
&& DEFAULT_COEFFICIENT.compareTo(value.getHeightFactor()) == 0
&& Boolean.TRUE.equals(value.getDefaultRule()) && Integer.valueOf(2).equals(value.getRoundingScale())
&& "HALF_UP".equals(value.getRoundingMode()) && "CNY".equals(value.getCurrency())
&& value.getEffectiveTo() == null && "ACTIVE".equals(value.getStatus())
&& DEFAULT_REMARK.equals(value.getRemark());
}
void deleteUnchangedDefault(long tenantId, String initializationKey) {
if (repository.deleteUnchangedInitializedRule(tenantId, initializationKey) != 1) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户权重系数默认规则补偿删除失败");
}
}
private static String requireCustomerName(String value) {
if (value == null || value.isBlank() || value.trim().length() > 128) {
throw new BizException(ResultCode.BAD_REQUEST, "客户名称无效");
}
return value.trim();
}
}
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.RuleResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.RuleUpdateRequest;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest;
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
@@ -32,6 +33,8 @@ public class CustomerDimensionConfigService {
@Resource
private CustomerDimensionConfigRepository repository;
@Resource
private CustomerChargeRuleInitializationService chargeRuleInitialization;
public GroupResponse get(long tenantId) {
return response(tenantId, requireCompleteRules(repository.findAll(tenantId)));
@@ -78,18 +81,26 @@ public class CustomerDimensionConfigService {
}
@Transactional
public InitializeResponse initializeDefault(long tenantId, long operatorId, String initializationKey) {
public InitializeResponse initializeDefault(long tenantId, long operatorId, String initializationKey,
String customerName) {
requireTenant(tenantId);
String normalizedKey = requireInitializationKey(initializationKey);
List<CustomerDimensionConfigEntity> existing = repository.findAll(tenantId);
if (!existing.isEmpty()) {
List<CustomerDimensionConfigEntity> complete = requireCompleteRules(existing);
return initializationResponse(complete, normalizedKey);
boolean ownedByKey = ownedByInitialization(complete, normalizedKey);
CustomerChargeRuleEntity chargeRule = ownedByKey
? chargeRuleInitialization.initializeDefault(
tenantId, operatorId, normalizedKey, customerName)
: null;
return initializationResponse(complete, chargeRule, ownedByKey);
}
repository.insertDefaults(defaultRows(tenantId, normalizedKey), operatorId);
List<CustomerDimensionConfigEntity> persisted = requireCompleteRules(repository.findAll(tenantId));
return initializationResponse(persisted, normalizedKey);
CustomerChargeRuleEntity chargeRule = chargeRuleInitialization.initializeDefault(
tenantId, operatorId, normalizedKey, customerName);
return initializationResponse(persisted, chargeRule, true);
}
@Transactional
@@ -99,6 +110,12 @@ public class CustomerDimensionConfigService {
List<CustomerDimensionConfigEntity> locked =
repository.findDefaultsForCompensation(tenantId, normalizedKey);
if (!isUnchangedDefaultGroup(locked, normalizedKey)) return;
CustomerChargeRuleEntity chargeRule = chargeRuleInitialization.lockForCompensation(
tenantId, normalizedKey);
if (chargeRule != null && !chargeRuleInitialization.isUnchangedDefault(chargeRule)) return;
if (chargeRule != null) {
chargeRuleInitialization.deleteUnchangedDefault(tenantId, normalizedKey);
}
if (repository.deleteUnchangedDefaults(tenantId, normalizedKey) != CustomerDimensionBoxType.values().length) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户箱规默认配置补偿删除失败");
}
@@ -206,11 +223,16 @@ public class CustomerDimensionConfigService {
return true;
}
private static boolean ownedByInitialization(List<CustomerDimensionConfigEntity> values,
String initializationKey) {
return values.stream().allMatch(value -> initializationKey.equals(value.getInitializationKey()));
}
private static InitializeResponse initializationResponse(List<CustomerDimensionConfigEntity> values,
String initializationKey) {
boolean ownedByKey = values.stream()
.allMatch(value -> initializationKey.equals(value.getInitializationKey()));
return new InitializeResponse(values.get(0).getId(), ownedByKey);
CustomerChargeRuleEntity chargeRule,
boolean ownedByKey) {
return new InitializeResponse(values.get(0).getId(),
chargeRule == null ? null : chargeRule.getId(), ownedByKey);
}
private static GroupResponse response(long tenantId, List<CustomerDimensionConfigEntity> values) {
@@ -44,7 +44,6 @@ public class FbaPricingService {
private static final BigDecimal ADJUST_TOLERANCE_PERCENT = new BigDecimal("10.00");
private static final BigDecimal ONE_HUNDRED = new BigDecimal("100");
private static final long AUTOMATIC_CONFIRM_OPERATOR_ID = 0L;
private static final String AUTOMATIC_CONFIRM_OPERATOR_NAME = "装柜完成自动确认";
private static final String CONTAINER_RECALCULATE_OPERATION = "CONTAINER_FEE_RECALCULATE";
@Resource
private FbaPricingRepository repository;
@@ -99,8 +98,8 @@ public class FbaPricingService {
if (published == null || !"SENT".equals(published.getFeeStatus())) {
throw conflict("费用状态已变化,请刷新后重试");
}
if (published.getCustomerConfirmedAt() != null) {
throw conflict("客户已确认该费用,不允许再修改或重新发送");
if (feeRevisionLocked(inbound, published)) {
throw conflict("客户已确认且该票已装柜完成,不允许再修改或重新发送费用");
}
}
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
@@ -167,7 +166,7 @@ public class FbaPricingService {
repository.insertItem(baseItem(tenantId, fee, rate, chargeable, subtotal, now));
if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, fee, additional, now));
if (repository.markInboundCalculated(tenantId, inboundId, inbound.getProcessStatus(),
estimate.chargeUnit(), chargeable) != 1) {
estimate.chargeUnit(), weight, volume, chargeable) != 1) {
throw conflict("入库单状态已变化,费用试算未保存");
}
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
@@ -343,7 +342,8 @@ public class FbaPricingService {
recalculated.getSubtotalAmount(), now));
if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, recalculated, additional, now));
if (repository.markInboundContainerFeeRecalculated(tenantId, member.inbound().getId(),
member.inbound().getProcessStatus(), estimate.chargeUnit(), member.chargeableQuantity()) != 1) {
member.inbound().getProcessStatus(), member.current().getActualWeightKg(),
member.current().getActualVolumeCbm(), estimate.chargeUnit(), member.chargeableQuantity()) != 1) {
throw conflict("柜内入库单状态已变化,不能重算阶梯费用");
}
feeDetails.materializeCalculatedFreight(tenantId, member.inbound().getId(), recalculated.getId(),
@@ -390,8 +390,8 @@ public class FbaPricingService {
public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId,
String idempotencyKey) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!feeEditableBeforeLoading(inbound.getProcessStatus())) {
throw conflict("订单已进入装柜流程,不能再修改或发送费用");
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
throw conflict("当前入库单状态不允许确认费用");
}
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
if (current == null) throw conflict("入库单尚无可确认的费用试算");
@@ -412,8 +412,8 @@ public class FbaPricingService {
public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId,
String idempotencyKey) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!feeEditableBeforeLoading(inbound.getProcessStatus())) {
throw conflict("订单已进入装柜流程,不能再修改或发送费用");
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
throw conflict("当前入库单状态不允许发送费用");
}
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
if (current == null) throw conflict("入库单尚无费用快照");
@@ -445,21 +445,6 @@ public class FbaPricingService {
return response(tenantId, fee.getId(), null);
}
/** 客户未操作时,装柜完成以系统身份确认最新一版已发送费用。 */
@Transactional
public void autoConfirmAfterLoading(long tenantId, long inboundId) {
InboundFeeEntity current = repository.findLatestFeeForUpdate(tenantId, inboundId);
if (current == null || !"SENT".equals(current.getFeeStatus())
|| current.getCustomerConfirmedAt() != null) {
return;
}
LocalDateTime now = LocalDateTime.now(clock);
if (repository.confirmFeeByCustomer(tenantId, current.getId(), current.getFeeVersion(),
AUTOMATIC_CONFIRM_OPERATOR_ID, AUTOMATIC_CONFIRM_OPERATOR_NAME, now) != 1) {
throw conflict("装柜完成自动确认费用失败,请重试");
}
}
@Transactional
public FeeSnapshotResponse confirmByCustomer(long tenantId, long operatorId, String operatorName,
long inboundId, int expectedFeeVersion,
@@ -498,15 +483,15 @@ public class FbaPricingService {
AdjustFeeRequest request, String idempotencyKey) {
InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
if (!isCompletedInbound(inbound.getProcessStatus())) {
throw conflict("只能在待分柜阶段修改初算或已发布费用");
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
throw conflict("当前入库单状态不允许修改初算或已发布费用");
}
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
if (current == null || !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) {
throw conflict("入库单没有可修改的初算或已发布费用");
}
if (current.getCustomerConfirmedAt() != null) {
throw conflict("客户已确认该费用,不允许再修改");
if (feeRevisionLocked(inbound, current)) {
throw conflict("客户已确认且该票已装柜完成,不允许再修改费用");
}
if (!request.expectedFeeVersion().equals(current.getFeeVersion())) {
throw conflict("费用版本已变化,请刷新后重试");
@@ -554,6 +539,9 @@ public class FbaPricingService {
adjusted.setConfirmedAt(published ? now : null);
adjusted.setSentBy(published ? operatorId : null);
adjusted.setSentAt(published ? now : null);
adjusted.setCustomerConfirmedBy(null);
adjusted.setCustomerConfirmedByName(null);
adjusted.setCustomerConfirmedAt(null);
adjusted.setCreatedAt(now);
adjusted.setUpdatedAt(now);
if (repository.supersedeCurrent(tenantId, current.getId(), current.getFeeStatus()) != 1) {
@@ -576,8 +564,8 @@ public class FbaPricingService {
extra.setRemark(request.reason().trim());
repository.insertItem(extra);
}
if (repository.markInboundAdjusted(tenantId, inboundId, adjusted.getFeeStatus(),
adjusted.getChargeUnit(), quantity) != 1) {
if (repository.markInboundAdjusted(tenantId, inboundId, inbound.getProcessStatus(), adjusted.getFeeStatus(),
adjusted.getActualWeightKg(), adjusted.getActualVolumeCbm(), adjusted.getChargeUnit(), quantity) != 1) {
throw conflict("入库单状态已变化,费用调整未保存");
}
if (published) {
@@ -594,7 +582,7 @@ public class FbaPricingService {
private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!feeEditableBeforeLoading(inbound.getProcessStatus())
if (!feeRevisionStatusSupported(inbound.getProcessStatus())
|| !List.of("UNPRICED", "CALCULATED", "SENT").contains(inbound.getFeeStatus())) {
throw conflict("当前入库单状态不允许费用试算");
}
@@ -613,20 +601,23 @@ public class FbaPricingService {
return inbound;
}
private static boolean isCompletedInbound(String processStatus) {
return "WAIT_SPLIT".equals(processStatus);
}
private static boolean preserveProcessStatusDuringPricing(String processStatus) {
return List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING").contains(processStatus);
return List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")
.contains(processStatus);
}
private static boolean feeEditableBeforeLoading(String processStatus) {
private static boolean feeRevisionStatusSupported(String processStatus) {
return List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
"WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING")
"WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING",
"LOADED", "DELIVERED")
.contains(processStatus);
}
private static boolean feeRevisionLocked(InboundPricingEntity inbound, InboundFeeEntity fee) {
return fee != null && fee.getCustomerConfirmedAt() != null
&& List.of("LOADED", "DELIVERED").contains(inbound.getProcessStatus());
}
private static PricingDestinationType destinationType(InboundPricingEntity inbound) {
try {
return PricingDestinationType.valueOf(inbound.getDestinationType());
@@ -92,6 +92,27 @@ public class InboundCartonLabelPrintService {
return ResponseEntity.status(response.getStatusCode()).headers(headers).body(response.getBody());
}
public ResponseEntity<byte[]> generateForPlatformInbounds(
List<InboundCartonLabels.PlatformBatchItem> items, String requestId) {
if (items == null || items.isEmpty() || items.size() > 200
|| items.stream().anyMatch(item -> item == null || item.tenantId() <= 0 || item.inboundId() <= 0)) {
throw new BizException(ResultCode.BAD_REQUEST, "批量打印必须选择1至200个入库单");
}
List<InboundCartonLabels.PlatformBatchItem> distinctItems = items.stream().distinct().toList();
List<InboundCartonLabels.Label> labels = new ArrayList<>();
for (InboundCartonLabels.PlatformBatchItem item : distinctItems) {
labels.addAll(labelService.list(item.tenantId(), item.inboundId(), null).labels());
}
if (labels.isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "所选订单没有可生成的箱唛");
}
ResponseEntity<byte[]> response = generatePdf("carton-labels-" + labels.size() + ".pdf", labels, requestId);
HttpHeaders headers = new HttpHeaders();
headers.putAll(response.getHeaders());
headers.set("X-Carton-Label-Order-Count", String.valueOf(distinctItems.size()));
return ResponseEntity.status(response.getStatusCode()).headers(headers).body(response.getBody());
}
/** 一件代发托盘标签:托盘号、入仓码、SKU箱数和托盘二维码。 */
public ResponseEntity<byte[]> generateDropshipPallet(Pallet pallet, String requestId) {
if (pallet == null || !StringUtils.hasText(pallet.palletNo()) || pallet.items().isEmpty()) {
@@ -12,6 +12,7 @@ import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundCartonLabelRepository;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -117,7 +118,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
throw new BizException(ResultCode.INTERNAL_ERROR, "预入库单补建后未能读取");
}
return prepared;
} catch (FeignException exception) {
} catch (FeignException | NoFallbackAvailableException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法补建预入库单");
}
}
@@ -181,7 +182,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
.thenComparing(OrderCargoClient.CargoLabelBinding::cargoId,
java.util.Comparator.nullsLast(Long::compareTo)))
.toList();
} catch (FeignException exception) {
} catch (FeignException | NoFallbackAvailableException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法生成唛头");
}
}
@@ -20,6 +20,7 @@ import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -61,6 +62,8 @@ public class InboundMeasurementService {
@Resource
private MeasurementBatchService batchService;
@Resource
private FbaPricingRepository pricingRepository;
@Resource
private FbaMasterDataService fbaMasterDataService;
@Resource
private OrderCargoClient orderCargoClient;
@@ -162,6 +165,68 @@ public class InboundMeasurementService {
return new InboundMeasurementResponses.DraftSaved(saved.getId(), saved.getVersion(), saved.getStatus());
}
@Transactional
public InboundMeasurementResponses.CalculationStrategyUpdated updateCalculationStrategy(
long tenantId, long inboundId,
InboundMeasurementRequests.UpdateCalculationStrategyRequest request) {
InboundOrderEntity inbound = repository.lockInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
requireMeasurementRevisionEditable(tenantId, inboundId, inbound, "箱规计算规则");
if ("MULTIPLY".equals(request.coefficientMode()) && request.chargeRuleId() == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "箱规系数模式必须选择计费规则");
}
InboundMeasurementEntity measurement = repository.lockMeasurement(
tenantId, inboundId, request.measurementId());
if (measurement == null) throw new BizException(ResultCode.NOT_FOUND, "测量批次不存在");
requireVersion(request.version(), measurement.getVersion());
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
batchService.recalculateStrategy(tenantId, measurement, items,
request.coefficientMode(), request.chargeRuleId());
measurement.setUpdatedAt(LocalDateTime.now());
if (!repository.updateMeasurement(measurement)) throw concurrentModification();
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
if (!repository.updateInbound(inbound)) throw concurrentModification();
Long appliedRuleId = "MULTIPLY".equals(request.coefficientMode()) ? request.chargeRuleId() : null;
return new InboundMeasurementResponses.CalculationStrategyUpdated(
measurement.getId(), measurement.getVersion(), measurement.getStatus(),
request.coefficientMode(), appliedRuleId);
}
@Transactional
public InboundMeasurementResponses.OuterDimensionsUpdated updateOuterDimensions(
long tenantId, long inboundId, long itemId,
InboundMeasurementRequests.UpdateOuterDimensionsRequest request) {
InboundOrderEntity inbound = repository.lockInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
requireMeasurementRevisionEditable(tenantId, inboundId, inbound, "仓库外箱尺寸");
InboundMeasurementEntity measurement = repository.lockMeasurement(
tenantId, inboundId, request.measurementId());
if (measurement == null) throw new BizException(ResultCode.NOT_FOUND, "测量批次不存在");
requireVersion(request.version(), measurement.getVersion());
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
batchService.recalculateOuterDimensions(tenantId, measurement, items, itemId,
request.outerLengthCm(), request.outerWidthCm(), request.outerHeightCm());
measurement.setUpdatedAt(LocalDateTime.now());
if (!repository.updateMeasurement(measurement)) throw concurrentModification();
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
if (!repository.updateInbound(inbound)) throw concurrentModification();
return new InboundMeasurementResponses.OuterDimensionsUpdated(
measurement.getId(), measurement.getVersion(), measurement.getStatus(), itemId);
}
private void requireMeasurementRevisionEditable(long tenantId, long inboundId,
InboundOrderEntity inbound, String subject) {
var currentFee = pricingRepository.findLatestFeeForUpdate(tenantId, inboundId);
boolean customerConfirmed = currentFee != null && currentFee.getCustomerConfirmedAt() != null;
boolean loadingCompleted = Set.of("LOADED", "DELIVERED").contains(inbound.getProcessStatus());
if (customerConfirmed && loadingCompleted) {
throw new BizException(ResultCode.CONFLICT,
"客户已确认且该票已装柜完成,不能修改" + subject);
}
}
@Transactional
public InboundMeasurementResponses.MeasurementSubmitted submit(
long tenantId, long inboundId, long operatorId,
@@ -180,6 +245,10 @@ public class InboundMeasurementService {
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
ConfirmedWeightTotals confirmedTotals = confirmedWeightTotals(weights);
if (confirmedTotals.palletCount() == 0) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"请至少上传并确认一张称重图片后再提交测量审核");
}
requireConfirmedPackageTotal(inbound, items);
measurement.setStatus(STATUS_SUBMITTED);
@@ -246,7 +246,7 @@ public class LoadingPickService {
public InventorySnapshot inventorySnapshot(long taskId, long warehouseId) {
LoadingPickTaskEntity task = requiredTask(taskId);
List<LoadingPickItemEntity> items = warehouseScopedItems(task, warehouseId);
List<LoadingPickItemEntity> items = taskItems(task, warehouseId);
return buildInventorySnapshot(task, warehouseId, items);
}
@@ -326,10 +326,7 @@ public class LoadingPickService {
assertReplay(replay, taskId, itemId, requestHash);
return view(task, request.warehouseId());
}
LoadingPickItemEntity item = repository.item(taskId, itemId, request.warehouseId());
if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在");
}
LoadingPickItemEntity item = requireTaskItem(taskId, itemId, request.warehouseId());
requirePackageUnit(item);
if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) {
throw conflict("拣货数量超过计划箱数或明细版本已变化");
@@ -404,7 +401,7 @@ public class LoadingPickService {
assertReplay(replay, taskId, itemId, requestHash);
return view(task, request.warehouseId());
}
LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId());
LoadingPickItemEntity item = requireTaskItem(taskId, itemId, request.warehouseId());
requirePackageUnit(item);
if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(),
request.warehouseId()) != 1) {
@@ -437,7 +434,7 @@ public class LoadingPickService {
assertReplay(replay, taskId, itemId, requestHash);
return view(task, request.warehouseId());
}
LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId());
LoadingPickItemEntity item = requireTaskItem(taskId, itemId, request.warehouseId());
requirePackageUnit(item);
if (repository.hasPickedOrPartialItem(taskId, item.getInboundId())) {
throw conflict("同一入库单已存在已拣或部分拣货明细,不能跳拣并恢复智能装柜候选");
@@ -940,10 +937,10 @@ public class LoadingPickService {
return task;
}
private LoadingPickItemEntity requireWarehouseItem(long taskId, long itemId, long warehouseId) {
private LoadingPickItemEntity requireTaskItem(long taskId, long itemId, long warehouseId) {
LoadingPickItemEntity item = repository.item(taskId, itemId, warehouseId);
if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在或不属于当前仓库");
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在或不属于当前任务");
}
return item;
}
@@ -953,16 +950,12 @@ public class LoadingPickService {
if (booking == null || !Objects.equals(task.getContainerBookingId(), booking.getId())) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或柜次已非有效存续记录");
}
if (warehouseId != null && !Objects.equals(warehouseId, booking.getWarehouseId())) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或不属于当前仓库");
}
List<LoadingPickItemEntity> source = warehouseId == null
? repository.items(task.getId()) : repository.items(task.getId(), warehouseId);
Map<Long, List<CartonLabelRow>> cartonsByItem = repository.cartonLabels(task.getId()).stream()
.collect(Collectors.groupingBy(CartonLabelRow::getPickItemId));
Map<Long, Long> tenantByInbound = tenantByInbound(task.getId());
Map<Long, List<InventoryResource>> inventoryByInbound = inventoryByInbound(
booking.getWarehouseId(), source, tenantByInbound);
Map<Long, List<InventoryResource>> inventoryByInbound = inventoryByInbound(source, tenantByInbound);
List<PickItem> items = source.stream().map(item -> {
List<CartonLabelRow> cartons = cartonsByItem.getOrDefault(item.getId(), List.of());
CartonLabelRow first = cartons.isEmpty() ? null : cartons.get(0);
@@ -1011,11 +1004,11 @@ public class LoadingPickService {
private List<BatchLine> validateBatchLines(LoadingPickTaskEntity task, BatchRequest request) {
long taskId = task.getId();
List<LoadingPickItemEntity> allItems = repository.items(taskId);
List<LoadingPickItemEntity> scopedItems = repository.items(taskId, request.warehouseId());
if (allItems.isEmpty() || scopedItems.size() != allItems.size()) {
throw conflict("PDA任务柜次、仓库或客户归属不一致");
List<LoadingPickItemEntity> taskItems = repository.items(taskId, request.warehouseId());
if (allItems.isEmpty() || taskItems.size() != allItems.size()) {
throw conflict("PDA任务柜次或客户归属不一致");
}
Map<Long, LoadingPickItemEntity> itemsById = scopedItems.stream().collect(Collectors.toMap(
Map<Long, LoadingPickItemEntity> itemsById = taskItems.stream().collect(Collectors.toMap(
LoadingPickItemEntity::getId, item -> item));
Set<Long> unresolvedIds = allItems.stream().filter(item -> !isResolved(item))
.map(LoadingPickItemEntity::getId).collect(Collectors.toSet());
@@ -1033,7 +1026,7 @@ public class LoadingPickService {
LoadingPickItemEntity item = itemsById.get(requestItem.itemId());
if (item == null || item.getInboundId() == null || item.getLoadingPlanItemId() == null
|| item.getInboundDetailId() == null) {
throw conflict("PDA明细不属于当前任务、柜次、仓库或客户");
throw conflict("PDA明细不属于当前任务、柜次或客户");
}
if (!"SKIP".equals(requestItem.action())) {
inboundHasWork.put(item.getInboundId(), true);
@@ -1096,7 +1089,7 @@ public class LoadingPickService {
}
result.add(new BatchLine(item, requestItem, action, request.warehouseId()));
}
validateWholePallets(result, scopedItems, request.warehouseId());
validateWholePallets(result, taskItems, request.warehouseId());
result.sort(Comparator.comparing(line -> line.item().getId()));
return result;
}
@@ -1121,19 +1114,19 @@ public class LoadingPickService {
}
}
private List<LoadingPickItemEntity> warehouseScopedItems(LoadingPickTaskEntity task, long warehouseId) {
private List<LoadingPickItemEntity> taskItems(LoadingPickTaskEntity task, long warehouseId) {
List<LoadingPickItemEntity> allItems = repository.items(task.getId());
List<LoadingPickItemEntity> scopedItems = repository.items(task.getId(), warehouseId);
if (allItems.isEmpty() || scopedItems.size() != allItems.size()) {
throw conflict("PDA任务柜次、仓库或客户归属不一致");
List<LoadingPickItemEntity> taskItems = repository.items(task.getId(), warehouseId);
if (allItems.isEmpty() || taskItems.size() != allItems.size()) {
throw conflict("PDA任务柜次或客户归属不一致");
}
return scopedItems;
return taskItems;
}
private InventorySnapshot buildInventorySnapshot(LoadingPickTaskEntity task, long warehouseId,
List<LoadingPickItemEntity> items) {
Map<Long, List<InventoryResource>> inventoryByInbound = inventoryByInbound(
warehouseId, items, tenantByInbound(task.getId()));
items, tenantByInbound(task.getId()));
List<InboundInventory> inbounds = items.stream().map(LoadingPickItemEntity::getInboundId)
.filter(Objects::nonNull).distinct().sorted().map(inboundId -> {
List<InventoryResource> resources = inventoryByInbound.getOrDefault(inboundId, List.of());
@@ -1160,8 +1153,7 @@ public class LoadingPickService {
}));
}
private Map<Long, List<InventoryResource>> inventoryByInbound(long warehouseId,
List<LoadingPickItemEntity> items,
private Map<Long, List<InventoryResource>> inventoryByInbound(List<LoadingPickItemEntity> items,
Map<Long, Long> tenantByInbound) {
Map<Long, List<InventoryResource>> result = new HashMap<>();
for (Long inboundId : items.stream().map(LoadingPickItemEntity::getInboundId)
@@ -1169,7 +1161,6 @@ public class LoadingPickService {
Long tenantId = tenantByInbound.get(inboundId);
if (tenantId == null) throw conflict("PDA任务缺少入库单客户归属");
List<InventoryResource> resources = palletAllocations.list(tenantId, inboundId).stream()
.filter(resource -> Objects.equals(resource.getWarehouseId(), warehouseId))
.filter(resource -> "CONFIRMED".equals(resource.getAllocationStatus()))
.sorted(Comparator.comparing(InboundPalletAllocationEntity::getBlockId,
Comparator.nullsLast(Long::compareTo))
@@ -1222,7 +1213,7 @@ public class LoadingPickService {
private void validateInventorySnapshot(LoadingPickTaskEntity task, BatchRequest request,
List<BatchLine> lines) {
List<LoadingPickItemEntity> allItems = warehouseScopedItems(task, request.warehouseId());
List<LoadingPickItemEntity> allItems = taskItems(task, request.warehouseId());
InventorySnapshot current = buildInventorySnapshot(task, request.warehouseId(), allItems);
if (!current.snapshotToken().equals(request.inventorySnapshotToken().trim())) {
throw conflict("库存已变化,请刷新后重试");
@@ -93,6 +93,87 @@ public class MeasurementBatchService {
return measurement;
}
void recalculateStrategy(long tenantId, InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> existingItems,
String coefficientMode, Long chargeRuleId) {
if (existingItems.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "当前测量批次没有可重算的明细");
}
MeasurementBatchCommand command = new MeasurementBatchCommand(
tenantId, measurement.getInboundId(), measurement.getId(), measurement.getVersion(),
measurement.getSourceType(), measurement.getSourceCode(), measurement.getExternalEventNo(),
measurement.getDeviceCode(), measurement.getWarehouseCode(), measurement.getMeasuredAt(),
measurement.getRawPayloadJson(), measurement.getOriginalDocumentNo(),
measurement.getOriginalGrossWeightKg(), measurement.getOriginalVolumeCbm(),
measurement.getActualGoodsName(), measurement.getCreatedBy(),
existingItems.stream().map(item -> new MeasurementBatchCommand.Item(
item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), null, item.getSourceImageFileId(),
item.getOverLimitWarning(), item.getManualOuterLengthCm(), item.getManualOuterWidthCm(),
item.getManualOuterHeightCm(), chargeRuleId, coefficientMode, item.getRemark())).toList());
List<InboundMeasurementItemEntity> recalculated = calculateItems(
command, dimensionWeighting.load(tenantId));
for (int index = 0; index < existingItems.size(); index++) {
InboundMeasurementItemEntity target = existingItems.get(index);
InboundMeasurementItemEntity source = recalculated.get(index);
applyRecalculatedItem(target, source, false);
}
applyTotals(measurement, existingItems);
}
void recalculateOuterDimensions(long tenantId, InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> existingItems, long itemId,
BigDecimal outerLengthCm, BigDecimal outerWidthCm,
BigDecimal outerHeightCm) {
InboundMeasurementItemEntity target = existingItems.stream()
.filter(item -> item.getId() != null && item.getId() == itemId)
.findFirst()
.orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "测量明细不存在"));
MeasurementBatchCommand command = new MeasurementBatchCommand(
tenantId, measurement.getInboundId(), measurement.getId(), measurement.getVersion(),
measurement.getSourceType(), measurement.getSourceCode(), measurement.getExternalEventNo(),
measurement.getDeviceCode(), measurement.getWarehouseCode(), measurement.getMeasuredAt(),
measurement.getRawPayloadJson(), measurement.getOriginalDocumentNo(),
measurement.getOriginalGrossWeightKg(), measurement.getOriginalVolumeCbm(),
measurement.getActualGoodsName(), measurement.getCreatedBy(),
List.of(new MeasurementBatchCommand.Item(
target.getInboundDetailId(), target.getItemNo(), target.getBoxSpecification(),
target.getPackageCount(), target.getLengthCm(), target.getWidthCm(), target.getHeightCm(),
null, target.getSourceImageFileId(), target.getOverLimitWarning(), outerLengthCm,
outerWidthCm, outerHeightCm, target.getChargeRuleId(), target.getCoefficientMode(),
target.getRemark())));
InboundMeasurementItemEntity recalculated = calculateItems(
command, dimensionWeighting.load(tenantId)).get(0);
applyRecalculatedItem(target, recalculated, true);
applyTotals(measurement, existingItems);
}
private void applyRecalculatedItem(InboundMeasurementItemEntity target,
InboundMeasurementItemEntity source,
boolean updateManualDimensions) {
target.setGirthCm(source.getGirthCm());
target.setUnitVolumeCbm(source.getUnitVolumeCbm());
target.setVolumeRawCbm(source.getVolumeRawCbm());
target.setVolumeCbm(source.getVolumeCbm());
target.setTotalVolumeCbm(source.getTotalVolumeCbm());
target.setVolumeWeightKg(source.getVolumeWeightKg());
target.setChargeRuleId(source.getChargeRuleId());
target.setCoefficientMode(source.getCoefficientMode());
target.setCalculationRuleVersion(source.getCalculationRuleVersion());
target.setOuterLengthCm(source.getOuterLengthCm());
target.setOuterWidthCm(source.getOuterWidthCm());
target.setOuterHeightCm(source.getOuterHeightCm());
if (updateManualDimensions) {
target.setManualOuterLengthCm(source.getManualOuterLengthCm());
target.setManualOuterWidthCm(source.getManualOuterWidthCm());
target.setManualOuterHeightCm(source.getManualOuterHeightCm());
}
target.setUpdatedAt(LocalDateTime.now());
if (!repository.updateItem(target)) {
throw concurrentModification();
}
}
/**
* 人工补录会整批替换可编辑测量项,但不能抹掉同一项已冻结的扫码身份与设备取证。
* 只按入库货物明细和明细号精确匹配,避免把一个箱码猜测性地复制给其他测量项。
@@ -18,7 +18,11 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId,
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
FROM fl_container_booking b
WHERE b.id=#{containerBookingId} FOR UPDATE
WHERE b.id=#{containerBookingId}
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d
WHERE d.container_booking_id=b.id AND d.confirmed_at IS NOT NULL
AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
FOR UPDATE
""")
Context lockContext(@Param("containerBookingId") long containerBookingId);
@@ -27,6 +31,9 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
FROM fl_container_booking b
WHERE b.id=#{containerBookingId}
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d
WHERE d.container_booking_id=b.id AND d.confirmed_at IS NOT NULL
AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
""")
Context context(@Param("containerBookingId") long containerBookingId);
@@ -37,6 +44,9 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
FROM fl_container_booking b
WHERE b.id IN
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d
WHERE d.container_booking_id=b.id AND d.confirmed_at IS NOT NULL
AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
ORDER BY b.id
</script>
""")
@@ -45,8 +45,10 @@ public class ContainerWorkbenchService {
public WorkbenchItem overseasDetail(long id, Set<String> permissions) {
ContainerBookingEntity value = required(id);
if (!repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国外工作台货柜不存在");
View drayage = terminalDrayages.authorize(terminalDrayages.detail(id), permissions);
return item("OVERSEAS", value, drayage.actualFacts(), drayage, permissions);
View drayage = terminalDrayages.authorize(terminalDrayages.views(List.of(id)), permissions).stream()
.filter(candidate -> candidate.containerBookingId() == id)
.findFirst().orElse(null);
return item("OVERSEAS", value, drayage == null ? null : drayage.actualFacts(), drayage, permissions);
}
private PageResult<WorkbenchItem> page(String workbench, Query query, Set<String> permissions) {
@@ -5,7 +5,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest;
@@ -123,7 +123,7 @@ public class TerminalDrayageService {
requireVersion(value, request.expectedVersion());
if (value.getFleetSupplierId() == null) throw conflict("请先选择有效码头车队");
if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可修改预约提柜时间");
if (context.facts().arrivedDestinationPortAt() != null
if (context.facts() != null && context.facts().arrivedDestinationPortAt() != null
&& request.pickupAppointmentAt().isBefore(context.facts().arrivedDestinationPortAt())) {
throw badRequest("预约提柜时间不能早于实际到港时间");
}
@@ -149,7 +149,8 @@ public class TerminalDrayageService {
throw conflict("选择码头车队并预约提柜后才能确认实际提柜");
}
if (actualPickupAt(context, value) != null) throw conflict("实际提柜时间已确认");
if (context.facts().latestActualAt() != null && request.actualAt().isBefore(context.facts().latestActualAt())) {
if (context.facts() != null && context.facts().latestActualAt() != null
&& request.actualAt().isBefore(context.facts().latestActualAt())) {
throw badRequest("实际提柜时间不能早于当前实际船节点时间");
}
advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key,
@@ -192,14 +193,11 @@ public class TerminalDrayageService {
private void advanceNode(WorkContext context, CarrierScheduleNode target, LocalDateTime actualAt,
String action, String key, String reason, long operatorId, String operatorName) {
if (context.facts().queryId() == null || context.facts().queryVersion() == null) {
throw conflict("货柜尚未建立船节点跟踪任务");
}
CarrierScheduleNode current = node(context.facts().latestActualNode());
CarrierScheduleNode current = currentNode(context);
if (current != null && current.sequence() >= target.sequence()) return;
carrierSchedules.addManualNode(context.facts().queryId(), operatorId, operatorName,
carrierSchedules.addContainerManualNode(context.base().containerBookingId(), operatorId, operatorName,
"td-" + hash(context.base().containerBookingId() + "|" + action + "|" + key.trim()),
new ManualNodeRequest(context.facts().queryVersion(), target.name(), LocalDateTime.now(clock), null,
new ContainerManualNodeRequest(target.name(), LocalDateTime.now(clock), null,
actualAt, null, null, StringUtils.hasText(reason) ? reason.trim() : "码头拖车操作同步"));
}
@@ -292,7 +290,7 @@ public class TerminalDrayageService {
List<String> result = new ArrayList<>();
if (value != null && value.getFleetSupplierId() != null && actualPickupAt(context, value) == null
&& actualEmptyReturnAt(context, value) == null && context.base().unloadingCompletedAt() == null) result.add("SEND_PICKUP_EMAIL");
CarrierScheduleNode current = node(context.facts().latestActualNode());
CarrierScheduleNode current = currentNode(context);
if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
&& current.sequence() < CarrierScheduleNode.PICKED_UP.sequence()
&& actualPickupAt(context, value) == null) {
@@ -312,7 +310,7 @@ public class TerminalDrayageService {
if (context.base().unloadingCompletedAt() != null) return "UNLOADED";
if (actualPickupAt(context, value) != null) return "PICKED_UP";
if (value != null && value.getPickupAppointmentAt() != null) return "PICKUP_SCHEDULED";
CarrierScheduleNode current = node(context.facts().latestActualNode());
CarrierScheduleNode current = currentNode(context);
return current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
? "ARRIVED" : "WAITING_FOR_ARRIVAL";
}
@@ -322,7 +320,7 @@ public class TerminalDrayageService {
}
private void requireAtLeast(WorkContext context, CarrierScheduleNode required, String message) {
CarrierScheduleNode current = node(context.facts().latestActualNode());
CarrierScheduleNode current = currentNode(context);
if (current == null || current.sequence() < required.sequence()) throw conflict(message);
}
@@ -330,13 +328,18 @@ public class TerminalDrayageService {
return StringUtils.hasText(value) ? CarrierScheduleNode.require(value) : null;
}
private CarrierScheduleNode currentNode(WorkContext context) {
return context.facts() == null ? null : node(context.facts().latestActualNode());
}
private LocalDateTime actualPickupAt(WorkContext context, TerminalDrayageEntity value) {
return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt() : context.facts().pickedUpAt();
return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt()
: context.facts() == null ? null : context.facts().pickedUpAt();
}
private LocalDateTime actualEmptyReturnAt(WorkContext context, TerminalDrayageEntity value) {
return value != null && value.getActualEmptyReturnAt() != null
? value.getActualEmptyReturnAt() : context.facts().emptyReturnedAt();
? value.getActualEmptyReturnAt() : context.facts() == null ? null : context.facts().emptyReturnedAt();
}
private WorkContext requiredContext(long id) {
@@ -362,8 +365,7 @@ public class TerminalDrayageService {
}
private WorkContext context(Context base, ContainerActualFacts facts) {
if (base == null || !"ACTIVE".equals(base.recordStatus()) || facts == null
|| facts.departedWarehouseAt() == null) return null;
if (base == null || !"ACTIVE".equals(base.recordStatus())) return null;
return new WorkContext(base, facts);
}