Test #1
@@ -8,3 +8,7 @@ spring:
|
||||
baiyue.clients:
|
||||
# 容器环境默认通过 Nacos 的 warehouse-service 实例调用;仅端口回归时显式设置直连 URL。
|
||||
warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||
|
||||
baiyue.order:
|
||||
# 两套系统并跑期间统一使用旧系统入仓码;正式切换后改为 AUTO。
|
||||
inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:LEGACY_MANUAL}
|
||||
|
||||
@@ -5,3 +5,7 @@ spring.datasource:
|
||||
password: ${ORDER_DB_PASSWORD:123456}
|
||||
baiyue.clients:
|
||||
warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||
|
||||
baiyue.order:
|
||||
# 两套系统并跑期间统一使用旧系统入仓码;正式切换后改为 AUTO。
|
||||
inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:LEGACY_MANUAL}
|
||||
|
||||
@@ -31,6 +31,15 @@ public interface OrderCargoClient {
|
||||
@PathVariable long tenantId,
|
||||
@PathVariable long orderId);
|
||||
|
||||
@GetMapping("/{tenantId}/{orderId}/measurement-declaration")
|
||||
ApiResponse<MeasurementDeclarationSnapshot> measurementDeclaration(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable long tenantId, @PathVariable long orderId);
|
||||
|
||||
record MeasurementDeclarationSnapshot(
|
||||
String referenceId, BigDecimal originalGrossWeightKg,
|
||||
BigDecimal originalVolumeCbm, String defaultActualGoodsName) {}
|
||||
|
||||
record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
|
||||
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
|
||||
BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn,
|
||||
|
||||
+1
-1
@@ -190,7 +190,7 @@ public class PlatformInboundMeasurementController {
|
||||
resourceId = "#resultId", description = "确认 OCR 重量并累加实际总毛重")
|
||||
@Operation(summary = "确认 OCR 重量",
|
||||
description = "可在同一事务中携带人工毛重修改值并确认;统一换算 KG、扣除托盘皮重后,"
|
||||
+ "按所有已确认净重重算入库单 actualGrossWeightKg。")
|
||||
+ "按所有已确认结果分别重算入库单毛重与扣皮后的货物净重。")
|
||||
@PostMapping("/weight-results/{resultId}/confirm")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:confirm')"
|
||||
+ " and (!#request.hasAdjustment() or hasAuthority('platform:firstleg:inbound:weight:edit'))")
|
||||
|
||||
-36
@@ -6,7 +6,6 @@ import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
|
||||
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
|
||||
import com.baiyue.firstleg.dto.MeasurementApprovalResponse;
|
||||
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
|
||||
@@ -57,41 +56,6 @@ public class PlatformInboundMeasurementReviewController {
|
||||
request.measurementId(), request.expectedVersion() + 1));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "INBOUND_MEASURE_REJECT", resourceType = "INBOUND",
|
||||
resourceId = "#inboundId", description = "测量审核驳回")
|
||||
@Operation(summary = "测量审核驳回",
|
||||
description = "驳回原因必填,只退回测量数据修改;已确认的托盘和库位占用保持不动。")
|
||||
@PostMapping("/{inboundId}/measurements/reject")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')")
|
||||
public ApiResponse<Void> reject(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long inboundId,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody MeasurementReviewRequest request) {
|
||||
validateScope(user, tenantId);
|
||||
service.reject(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "INBOUND_MEASURE_EXCEPTION_RELEASE", resourceType = "INBOUND",
|
||||
resourceId = "#inboundId", description = "测量驳回异常放行")
|
||||
@Operation(summary = "测量驳回异常放行",
|
||||
description = "Nacos 开关开启、有异常放行权限且原因非空时,才可从 MEASURE_REJECTED 推进至 WAIT_SPLIT。")
|
||||
@PostMapping("/{inboundId}/measurements/exception-release")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:exception-release')")
|
||||
public ApiResponse<Void> exceptionRelease(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long inboundId,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody ExceptionReleaseRequest request) {
|
||||
validateScope(user, tenantId);
|
||||
service.exceptionRelease(
|
||||
tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@Operation(summary = "查询测量审核历史")
|
||||
@GetMapping("/{inboundId}/measurement-review-history")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:measurement-review-history')")
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 当前业务单元所关联柜次的正式运输事实。 */
|
||||
public record CurrentContainerTransport(
|
||||
Long inboundId, Long accountId, Long containerBookingId,
|
||||
String containerNo, String cabinetNo, String vesselName, String voyageNo,
|
||||
LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt,
|
||||
LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt
|
||||
) {}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAnySetter;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.DecimalMax;
|
||||
@@ -26,12 +27,13 @@ public final class InboundMeasurementRequests {
|
||||
public record SaveDraftRequest(
|
||||
Long measurementId,
|
||||
@NotNull @PositiveOrZero Integer version,
|
||||
@Size(max = 64) String originalDocumentNo,
|
||||
@DecimalMin("0.000") @Digits(integer = 15, fraction = 3) BigDecimal originalGrossWeightKg,
|
||||
@DecimalMin("0.0000") @Digits(integer = 14, fraction = 4) BigDecimal originalVolumeCbm,
|
||||
@Size(max = 255) String actualGoodsName,
|
||||
@NotEmpty @Size(max = 500) List<@Valid ItemRequest> items
|
||||
) {
|
||||
@JsonAnySetter
|
||||
public void rejectUnknownField(String fieldName, Object ignored) {
|
||||
throw new IllegalArgumentException("测量草稿不接受字段: " + fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
public record ItemRequest(
|
||||
|
||||
@@ -40,6 +40,7 @@ public final class InboundMeasurementResponses {
|
||||
Integer originalPackageCount,
|
||||
Integer actualPackageCount,
|
||||
BigDecimal actualGrossWeightKg,
|
||||
BigDecimal actualNetWeightKg,
|
||||
BigDecimal actualVolumeCbm,
|
||||
BigDecimal volumeWeightKg,
|
||||
String actualGoodsName,
|
||||
|
||||
@@ -28,12 +28,25 @@ public record InboundOrderSummary(
|
||||
LocalDateTime measureCompletedAt, LocalDateTime inboundedAt,
|
||||
String expressCompanyCode, String expressCompanyName, String expressTrackingNo,
|
||||
String containerNo, String cabinetNo,
|
||||
String vesselName, String voyageNo, LocalDateTime sailingAt, LocalDateTime portArrivalAt,
|
||||
String vesselName, String voyageNo,
|
||||
LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt,
|
||||
LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt,
|
||||
LocalDateTime createdAt, LocalDateTime updatedAt
|
||||
) {
|
||||
/** 非统一入仓接口保留既有快照适配;统一订单不得调用此重载。 */
|
||||
public static InboundOrderSummary from(
|
||||
InboundOrderEntity order, InboundTransportSnapshotEntity transport,
|
||||
InboundPickupSnapshotEntity pickup, String locationCodes) {
|
||||
CurrentContainerTransport adapted = transport == null ? null : new CurrentContainerTransport(
|
||||
order.getId(), order.getTenantId(), null, transport.getContainerNo(), transport.getCabinetNo(),
|
||||
transport.getVesselName(), transport.getVoyageNo(), transport.getSailingAt(), null,
|
||||
transport.getPortArrivalAt(), null);
|
||||
return from(order, adapted, pickup, locationCodes);
|
||||
}
|
||||
|
||||
public static InboundOrderSummary from(
|
||||
InboundOrderEntity order, CurrentContainerTransport transport,
|
||||
InboundPickupSnapshotEntity pickup, String locationCodes) {
|
||||
return new InboundOrderSummary(
|
||||
order.getId(), order.getTenantId(), order.getOrderId(), order.getOrderNo(), order.getInboundCode(),
|
||||
order.getProcessStatus(), order.getOrderStatusSnapshot(), order.getMeasureReviewStatus(),
|
||||
@@ -53,12 +66,14 @@ public record InboundOrderSummary(
|
||||
locationCodes,
|
||||
order.getMeasureCompletedAt(), order.getInboundedAt(), order.getExpressCompanyCode(),
|
||||
order.getExpressCompanyName(), order.getExpressTrackingNo(),
|
||||
transport == null ? null : transport.getContainerNo(),
|
||||
transport == null ? null : transport.getCabinetNo(),
|
||||
transport == null ? null : transport.getVesselName(),
|
||||
transport == null ? null : transport.getVoyageNo(),
|
||||
transport == null ? null : transport.getSailingAt(),
|
||||
transport == null ? null : transport.getPortArrivalAt(),
|
||||
transport == null ? null : transport.containerNo(),
|
||||
transport == null ? null : transport.cabinetNo(),
|
||||
transport == null ? null : transport.vesselName(),
|
||||
transport == null ? null : transport.voyageNo(),
|
||||
transport == null ? null : transport.estimatedSailingAt(),
|
||||
transport == null ? null : transport.actualSailingAt(),
|
||||
transport == null ? null : transport.estimatedPortArrivalAt(),
|
||||
transport == null ? null : transport.actualPortArrivalAt(),
|
||||
order.getCreatedAt(), order.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public record MerchantInboundBillingResponse(InboundOrderDetail inbound,
|
||||
|
||||
public record MeasurementSummary(
|
||||
Long measurementId, String sourceType, String measurementStatus,
|
||||
Integer actualPackageCount, BigDecimal actualGrossWeightKg,
|
||||
Integer actualPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg,
|
||||
BigDecimal actualVolumeCbm, BigDecimal volumeWeightKg, String actualGoodsName) {
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ public class InboundPricingEntity extends BaseEntity {
|
||||
private Long destinationWarehouseId;
|
||||
private String destinationName;
|
||||
private BigDecimal actualGrossWeightKg;
|
||||
private BigDecimal actualNetWeightKg;
|
||||
private BigDecimal actualVolumeCbm;
|
||||
private BigDecimal chargeableWeightKg;
|
||||
private BigDecimal chargeableVolumeCbm;
|
||||
|
||||
@@ -12,19 +12,14 @@ public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
|
||||
@Update("""
|
||||
UPDATE fl_inbound_order
|
||||
SET process_status = 'WAIT_SPLIT', measure_review_status = 'APPROVED',
|
||||
actual_gross_weight_kg = CASE
|
||||
WHEN EXISTS (SELECT 1
|
||||
FROM fl_inbound_weight_result weight
|
||||
WHERE weight.tenant_id = #{tenantId} AND weight.inbound_id = #{inboundId}
|
||||
AND weight.deleted = 0 AND weight.recognition_status = 'CONFIRMED')
|
||||
THEN (SELECT COALESCE(SUM(weight.net_weight_kg), 0)
|
||||
FROM fl_inbound_weight_result weight
|
||||
WHERE weight.tenant_id = #{tenantId} AND weight.inbound_id = #{inboundId}
|
||||
AND weight.deleted = 0 AND weight.recognition_status = 'CONFIRMED')
|
||||
ELSE (SELECT COALESCE(SUM(item.total_weight_kg), 0)
|
||||
FROM fl_inbound_measurement_item item
|
||||
WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId})
|
||||
END,
|
||||
actual_gross_weight_kg = (SELECT SUM(weight.gross_weight_kg)
|
||||
FROM fl_inbound_weight_result weight
|
||||
WHERE weight.tenant_id = #{tenantId} AND weight.inbound_id = #{inboundId}
|
||||
AND weight.deleted = 0 AND weight.recognition_status = 'CONFIRMED'),
|
||||
actual_net_weight_kg = (SELECT SUM(weight.net_weight_kg)
|
||||
FROM fl_inbound_weight_result weight
|
||||
WHERE weight.tenant_id = #{tenantId} AND weight.inbound_id = #{inboundId}
|
||||
AND weight.deleted = 0 AND weight.recognition_status = 'CONFIRMED'),
|
||||
actual_volume_cbm = (SELECT COALESCE(SUM(item.total_volume_cbm), 0)
|
||||
FROM fl_inbound_measurement_item item
|
||||
WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baiyue.firstleg.dto.CurrentContainerTransport;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 统一订单按当前有效装柜明细批量读取柜次船期。 */
|
||||
@Mapper
|
||||
public interface UnifiedInboundTransportMapper {
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT DISTINCT i.inbound_id, i.account_id, p.container_booking_id,
|
||||
b.container_no, b.cabinet_no, b.vessel_name, b.voyage_no,
|
||||
b.estimated_sailing_at, b.actual_sailing_at,
|
||||
b.estimated_port_arrival_at, b.actual_port_arrival_at
|
||||
FROM fl_loading_plan_item i
|
||||
JOIN fl_loading_plan p ON p.id = i.loading_plan_id
|
||||
JOIN fl_container_booking b ON b.id = p.container_booking_id
|
||||
WHERE i.deleted = 0
|
||||
AND p.plan_status IN ('LOADING', 'COMPLETED')
|
||||
AND i.inbound_id IN
|
||||
<foreach collection="inboundIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</script>
|
||||
""")
|
||||
List<CurrentContainerTransport> findCurrentByInboundIds(@Param("inboundIds") List<Long> inboundIds);
|
||||
}
|
||||
+2
-1
@@ -59,7 +59,8 @@ public class InboundMeasurementRepository {
|
||||
return inboundOrders.update(inbound, new LambdaUpdateWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, inbound.getTenantId())
|
||||
.eq(InboundOrderEntity::getId, inbound.getId())
|
||||
.set(InboundOrderEntity::getActualGrossWeightKg, inbound.getActualGrossWeightKg())) == 1;
|
||||
.set(InboundOrderEntity::getActualGrossWeightKg, inbound.getActualGrossWeightKg())
|
||||
.set(InboundOrderEntity::getActualNetWeightKg, inbound.getActualNetWeightKg())) == 1;
|
||||
}
|
||||
|
||||
public InboundMeasurementEntity findMeasurement(long tenantId, long inboundId, long measurementId) {
|
||||
|
||||
@@ -154,29 +154,31 @@ public class InboundOrderRepository {
|
||||
+ "OR (newer.fee_version = f.fee_version AND newer.id > f.id))))",
|
||||
query.feeReviewStatus())
|
||||
.apply(StringUtils.hasText(query.containerNo()),
|
||||
"EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t "
|
||||
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id "
|
||||
+ "AND t.container_no LIKE CONCAT('%', {0}, '%'))", query.containerNo())
|
||||
"EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id "
|
||||
+ "JOIN fl_container_booking b ON b.id=p.container_booking_id WHERE i.deleted=0 "
|
||||
+ "AND p.plan_status IN ('LOADING','COMPLETED') AND i.account_id=fl_inbound_order.tenant_id "
|
||||
+ "AND i.inbound_id=fl_inbound_order.id AND b.container_no LIKE CONCAT('%', {0}, '%'))", query.containerNo())
|
||||
.apply(StringUtils.hasText(query.cabinetNo()),
|
||||
"EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t "
|
||||
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id "
|
||||
+ "AND t.cabinet_no LIKE CONCAT('%', {0}, '%'))", query.cabinetNo())
|
||||
"EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id "
|
||||
+ "JOIN fl_container_booking b ON b.id=p.container_booking_id WHERE i.deleted=0 "
|
||||
+ "AND p.plan_status IN ('LOADING','COMPLETED') AND i.account_id=fl_inbound_order.tenant_id "
|
||||
+ "AND i.inbound_id=fl_inbound_order.id AND b.cabinet_no LIKE CONCAT('%', {0}, '%'))", query.cabinetNo())
|
||||
.apply(query.sailingDate() != null,
|
||||
"EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t "
|
||||
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id "
|
||||
+ "AND t.sailing_at >= {0} AND t.sailing_at < {1})",
|
||||
"EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id "
|
||||
+ "JOIN fl_container_booking b ON b.id=p.container_booking_id WHERE i.deleted=0 "
|
||||
+ "AND p.plan_status IN ('LOADING','COMPLETED') AND i.account_id=fl_inbound_order.tenant_id "
|
||||
+ "AND i.inbound_id=fl_inbound_order.id AND COALESCE(b.actual_sailing_at,b.estimated_sailing_at) "
|
||||
+ ">= {0} AND COALESCE(b.actual_sailing_at,b.estimated_sailing_at) < {1})",
|
||||
query.sailingDate() == null ? null : query.sailingDate().atStartOfDay(),
|
||||
query.sailingDate() == null ? null : query.sailingDate().plusDays(1).atStartOfDay())
|
||||
.apply(Boolean.TRUE.equals(query.loaded()),
|
||||
"EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t "
|
||||
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id "
|
||||
+ "AND COALESCE(TRIM(t.loading_status), '') <> '' "
|
||||
+ "AND t.loading_status NOT IN ('UNLOADED', 'PENDING'))")
|
||||
"EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id "
|
||||
+ "WHERE i.deleted=0 AND p.plan_status='COMPLETED' "
|
||||
+ "AND i.account_id=fl_inbound_order.tenant_id AND i.inbound_id=fl_inbound_order.id)")
|
||||
.apply(Boolean.FALSE.equals(query.loaded()),
|
||||
"NOT EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t "
|
||||
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id "
|
||||
+ "AND COALESCE(TRIM(t.loading_status), '') <> '' "
|
||||
+ "AND t.loading_status NOT IN ('UNLOADED', 'PENDING'))")
|
||||
"NOT EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id "
|
||||
+ "WHERE i.deleted=0 AND p.plan_status='COMPLETED' "
|
||||
+ "AND i.account_id=fl_inbound_order.tenant_id AND i.inbound_id=fl_inbound_order.id)")
|
||||
.ge(query.createdFrom() != null, InboundOrderEntity::getCreatedAt, query.createdFrom())
|
||||
.le(query.createdTo() != null, InboundOrderEntity::getCreatedAt, query.createdTo())
|
||||
.orderByDesc(InboundOrderEntity::getCreatedAt).orderByDesc(InboundOrderEntity::getId);
|
||||
|
||||
+19
-13
@@ -8,9 +8,11 @@ import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper;
|
||||
import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
|
||||
import com.baiyue.firstleg.split.mapper.PreloadPlanMapper;
|
||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
|
||||
import com.baiyue.firstleg.dto.CurrentContainerTransport;
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper;
|
||||
import com.baiyue.firstleg.mapper.UnifiedInboundTransportMapper;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@@ -26,7 +28,7 @@ public class UnifiedInboundReadRepository {
|
||||
@Resource private InboundLoadingPickTaskMapper picks;
|
||||
@Resource private LoadingEvidenceMapper evidences;
|
||||
@Resource private InboundStatusHistoryMapper histories;
|
||||
@Resource private InboundTransportSnapshotMapper transports;
|
||||
@Resource private UnifiedInboundTransportMapper transports;
|
||||
|
||||
public PreloadPlanEntity findPreload(long tenantId, long inboundId) {
|
||||
return preloads.selectOne(new LambdaQueryWrapper<PreloadPlanEntity>()
|
||||
@@ -84,19 +86,23 @@ public class UnifiedInboundReadRepository {
|
||||
Function.identity(), (latest, ignored) -> latest));
|
||||
}
|
||||
|
||||
public InboundTransportSnapshotEntity findTransport(long tenantId, long inboundId) {
|
||||
return transports.selectOne(new LambdaQueryWrapper<InboundTransportSnapshotEntity>()
|
||||
.eq(InboundTransportSnapshotEntity::getTenantId, tenantId)
|
||||
.eq(InboundTransportSnapshotEntity::getInboundId, inboundId));
|
||||
public CurrentContainerTransport findTransport(long tenantId, long inboundId) {
|
||||
return findTransports(Map.of(inboundId, tenantId)).get(inboundId);
|
||||
}
|
||||
|
||||
public Map<Long, InboundTransportSnapshotEntity> findTransports(Map<Long, Long> tenantByInbound) {
|
||||
public Map<Long, CurrentContainerTransport> findTransports(Map<Long, Long> tenantByInbound) {
|
||||
if (tenantByInbound.isEmpty()) return Map.of();
|
||||
return transports.selectList(new LambdaQueryWrapper<InboundTransportSnapshotEntity>()
|
||||
.in(InboundTransportSnapshotEntity::getInboundId, tenantByInbound.keySet()))
|
||||
.stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
|
||||
.collect(Collectors.toMap(InboundTransportSnapshotEntity::getInboundId,
|
||||
Function.identity(), (left, right) -> left));
|
||||
Map<Long, List<CurrentContainerTransport>> grouped = transports
|
||||
.findCurrentByInboundIds(List.copyOf(tenantByInbound.keySet())).stream()
|
||||
.filter(item -> owns(tenantByInbound, item.inboundId(), item.accountId()))
|
||||
.collect(Collectors.groupingBy(CurrentContainerTransport::inboundId));
|
||||
grouped.forEach((inboundId, rows) -> {
|
||||
long bookingCount = rows.stream().map(CurrentContainerTransport::containerBookingId).distinct().count();
|
||||
if (bookingCount > 1) throw new BizException(ResultCode.CONFLICT,
|
||||
"同一入库业务单元关联多个有效柜次: inboundId=" + inboundId);
|
||||
});
|
||||
return grouped.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey,
|
||||
entry -> entry.getValue().get(0)));
|
||||
}
|
||||
|
||||
private static boolean owns(Map<Long, Long> tenantByInbound, Long inboundId, Long tenantId) {
|
||||
|
||||
@@ -150,21 +150,24 @@ public class CartonMeasurementService {
|
||||
.filter(weight -> "CONFIRMED".equals(weight.getRecognitionStatus()))
|
||||
.toList();
|
||||
if (!confirmedWeights.isEmpty()) {
|
||||
if (confirmedWeights.stream().anyMatch(weight -> !positive(weight.getGrossWeightKg())
|
||||
|| !positive(weight.getNetWeightKg()))) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"已确认称重结果缺少有效毛重或净重,请重新确认后再修改箱数");
|
||||
}
|
||||
BigDecimal confirmedGrossWeight = confirmedWeights.stream()
|
||||
.map(InboundWeightResultEntity::getGrossWeightKg)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add)
|
||||
.setScale(3, RoundingMode.HALF_UP);
|
||||
BigDecimal confirmedNetWeight = confirmedWeights.stream()
|
||||
.map(InboundWeightResultEntity::getNetWeightKg)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add)
|
||||
.setScale(3, RoundingMode.CEILING);
|
||||
inbound.setActualGrossWeightKg(confirmedNetWeight);
|
||||
.setScale(3, RoundingMode.HALF_UP);
|
||||
inbound.setActualGrossWeightKg(confirmedGrossWeight);
|
||||
inbound.setActualNetWeightKg(confirmedNetWeight);
|
||||
} else {
|
||||
BigDecimal totalWeight = items.stream()
|
||||
.map(InboundMeasurementItemEntity::getTotalWeightKg)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add)
|
||||
.setScale(3, RoundingMode.CEILING);
|
||||
if (positive(totalWeight)) {
|
||||
inbound.setActualGrossWeightKg(totalWeight);
|
||||
}
|
||||
inbound.setActualGrossWeightKg(null);
|
||||
inbound.setActualNetWeightKg(null);
|
||||
}
|
||||
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
|
||||
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
|
||||
|
||||
@@ -58,9 +58,9 @@ public class FbaPricingService {
|
||||
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest(
|
||||
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
|
||||
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
|
||||
pricingTaxMode(inbound), inbound.getActualGrossWeightKg(), inbound.getActualVolumeCbm()));
|
||||
pricingTaxMode(inbound), inbound.getActualNetWeightKg(), inbound.getActualVolumeCbm()));
|
||||
RateRow rate = estimate.rate();
|
||||
BigDecimal weight = inbound.getActualGrossWeightKg().setScale(3, RoundingMode.HALF_UP);
|
||||
BigDecimal weight = inbound.getActualNetWeightKg().setScale(3, RoundingMode.HALF_UP);
|
||||
BigDecimal volume = inbound.getActualVolumeCbm().setScale(4, RoundingMode.HALF_UP);
|
||||
BigDecimal chargeable = estimate.chargeableQuantity();
|
||||
BigDecimal converted = chargeable;
|
||||
@@ -240,9 +240,9 @@ public class FbaPricingService {
|
||||
throw conflict("当前目的地类型不支持自动计费");
|
||||
}
|
||||
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) throw conflict("只能消费审核通过的测量快照");
|
||||
if (inbound.getActualGrossWeightKg() == null || inbound.getActualGrossWeightKg().signum() <= 0
|
||||
if (inbound.getActualNetWeightKg() == null || inbound.getActualNetWeightKg().signum() <= 0
|
||||
|| inbound.getActualVolumeCbm() == null || inbound.getActualVolumeCbm().signum() <= 0) {
|
||||
throw conflict("审核通过的测量快照必须包含大于零的实重和体积");
|
||||
throw conflict("审核通过的测量快照必须包含大于零的货物净重和体积");
|
||||
}
|
||||
if (inbound.getCustomerId() == null || inbound.getCustomerId() <= 0) {
|
||||
throw conflict("入库单缺少订单客户归属,不能生成对客费用");
|
||||
|
||||
@@ -390,7 +390,8 @@ public class InboundFeeDetailService {
|
||||
value.setDestinationCode(inbound.getDestinationCode());
|
||||
value.setDestinationName(inbound.getDestinationName());
|
||||
value.setPackageCount(inbound.getTotalPackageCount());
|
||||
value.setActualGrossWeightKg(inbound.getActualGrossWeightKg());
|
||||
// 财务历史字段名保留,但计费事实统一使用扣除托盘皮重后的货物净重。
|
||||
value.setActualGrossWeightKg(inbound.getActualNetWeightKg());
|
||||
value.setActualVolumeCbm(inbound.getActualVolumeCbm());
|
||||
value.setVolumeWeightKg(inbound.getVolumeWeightKg());
|
||||
value.setChargeableWeightKg(inbound.getChargeableWeightKg());
|
||||
|
||||
+118
-41
@@ -68,7 +68,9 @@ public class InboundMeasurementService {
|
||||
? List.of() : repository.listItems(tenantId, measurement.getId());
|
||||
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
|
||||
MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inboundId);
|
||||
return toDetail(inbound, measurement, items, weights, review);
|
||||
OrderCargoClient.MeasurementDeclarationSnapshot declaration = needsDeclarationFallback(measurement)
|
||||
? loadDeclaration(inbound) : null;
|
||||
return toDetail(inbound, measurement, items, weights, review, declaration);
|
||||
}
|
||||
|
||||
public List<InboundMeasurementResponses.PalletSnapshot> weightPalletOptions(
|
||||
@@ -87,11 +89,23 @@ public class InboundMeasurementService {
|
||||
InboundMeasurementRequests.SaveDraftRequest request) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
validateInboundDetails(tenantId, inbound, request.items());
|
||||
InboundMeasurementEntity existing = request.measurementId() == null ? null
|
||||
: repository.findMeasurement(tenantId, inboundId, request.measurementId());
|
||||
OrderCargoClient.MeasurementDeclarationSnapshot declaration = loadDeclaration(inbound);
|
||||
String actualGoodsName = StringUtils.hasText(request.actualGoodsName()) ? request.actualGoodsName().trim()
|
||||
: existing != null && StringUtils.hasText(existing.getActualGoodsName())
|
||||
? existing.getActualGoodsName() : defaultGoodsName(declaration);
|
||||
MeasurementBatchCommand command = new MeasurementBatchCommand(
|
||||
tenantId, inboundId, request.measurementId(), request.version(),
|
||||
MeasurementBatchService.SOURCE_MANUAL, "PLATFORM", null, null, null,
|
||||
LocalDateTime.now(), null, request.originalDocumentNo(),
|
||||
request.originalGrossWeightKg(), request.originalVolumeCbm(), request.actualGoodsName(),
|
||||
LocalDateTime.now(), null,
|
||||
existing != null && StringUtils.hasText(existing.getOriginalDocumentNo())
|
||||
? existing.getOriginalDocumentNo() : declaration.referenceId(),
|
||||
existing != null && existing.getOriginalGrossWeightKg() != null
|
||||
? existing.getOriginalGrossWeightKg() : declaration.originalGrossWeightKg(),
|
||||
existing != null && existing.getOriginalVolumeCbm() != null
|
||||
? existing.getOriginalVolumeCbm() : declaration.originalVolumeCbm(),
|
||||
actualGoodsName,
|
||||
operatorId, request.items().stream().map(item -> new MeasurementBatchCommand.Item(
|
||||
item.inboundDetailId(), item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(),
|
||||
item.widthCm(), item.heightCm(), item.unitWeightKg(), item.intelligentImageFileId(),
|
||||
@@ -116,10 +130,7 @@ public class InboundMeasurementService {
|
||||
}
|
||||
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
|
||||
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
|
||||
BigDecimal confirmedWeight = confirmedWeightForSubmission(weights);
|
||||
int confirmedPalletCount = Math.toIntExact(weights.stream()
|
||||
.filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))
|
||||
.count());
|
||||
ConfirmedWeightTotals confirmedTotals = confirmedWeightTotals(weights);
|
||||
|
||||
if (!alreadyAdvanced) {
|
||||
requireConfirmedPackageTotal(inbound, items);
|
||||
@@ -137,10 +148,9 @@ public class InboundMeasurementService {
|
||||
if (measurement.getTotalPackageCount() != null) {
|
||||
inbound.setTotalPackageCount(measurement.getTotalPackageCount());
|
||||
}
|
||||
if (confirmedWeight != null) {
|
||||
inbound.setActualGrossWeightKg(confirmedWeight);
|
||||
}
|
||||
inbound.setPalletCount(confirmedPalletCount);
|
||||
inbound.setActualGrossWeightKg(confirmedTotals.grossWeightKg());
|
||||
inbound.setActualNetWeightKg(confirmedTotals.netWeightKg());
|
||||
inbound.setPalletCount(confirmedTotals.palletCount());
|
||||
if (measurement.getTotalVolumeCbm() != null) {
|
||||
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
|
||||
}
|
||||
@@ -161,6 +171,7 @@ public class InboundMeasurementService {
|
||||
long tenantId, long inboundId, long operatorId,
|
||||
InboundMeasurementRequests.CreateWeightResultRequest request) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
requireWeightEditableInbound(inbound);
|
||||
if (repository.findWeightByFile(tenantId, inboundId, request.fileId()) != null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "同一称重图片不能重复计重");
|
||||
}
|
||||
@@ -192,6 +203,7 @@ public class InboundMeasurementService {
|
||||
weight.setDeleted(false);
|
||||
weight.setVersion(0);
|
||||
repository.insertWeight(weight);
|
||||
recalculateConfirmedWeight(inbound, operatorId);
|
||||
return toWeight(weight);
|
||||
}
|
||||
|
||||
@@ -200,14 +212,12 @@ public class InboundMeasurementService {
|
||||
long tenantId, long inboundId, long resultId, long operatorId, String operatorName,
|
||||
InboundMeasurementRequests.CorrectWeightResultRequest request) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
requireWeightEditableInbound(inbound);
|
||||
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
|
||||
requireVersion(request.version(), weight.getVersion());
|
||||
boolean wasConfirmed = WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus());
|
||||
applyWeightCorrection(weight, request.version(), request.adjustedWeightValue(),
|
||||
request.adjustedWeightUnit(), request.adjustmentReason(), operatorId, operatorName);
|
||||
if (wasConfirmed) {
|
||||
recalculateConfirmedWeight(inbound, operatorId);
|
||||
}
|
||||
recalculateConfirmedWeight(inbound, operatorId);
|
||||
return toWeight(weight);
|
||||
}
|
||||
|
||||
@@ -246,6 +256,7 @@ public class InboundMeasurementService {
|
||||
long tenantId, long inboundId, long resultId, long operatorId, String operatorName,
|
||||
InboundMeasurementRequests.ConfirmWeightResultRequest request) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
requireWeightConfirmableInbound(inbound, request.hasAdjustment());
|
||||
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
|
||||
requireVersion(request.version(), weight.getVersion());
|
||||
int currentVersion = request.version();
|
||||
@@ -291,19 +302,15 @@ public class InboundMeasurementService {
|
||||
|
||||
@Transactional
|
||||
public void deleteWeightResult(long tenantId, long inboundId, long resultId,
|
||||
int version, long operatorId) {
|
||||
int version, long operatorId) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
if (!WEIGHT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "当前入库状态不允许删除称重图片");
|
||||
}
|
||||
requireWeightEditableInbound(inbound);
|
||||
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
|
||||
requireVersion(version, weight.getVersion());
|
||||
if (!repository.deleteWeight(tenantId, inboundId, resultId, version)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) {
|
||||
recalculateConfirmedWeight(inbound, operatorId);
|
||||
}
|
||||
recalculateConfirmedWeight(inbound, operatorId);
|
||||
try {
|
||||
ApiResponse<Void> response = imageClient.delete(
|
||||
internalToken, operatorId, tenantId, inboundId, weight.getFileId());
|
||||
@@ -319,15 +326,10 @@ public class InboundMeasurementService {
|
||||
}
|
||||
|
||||
private void recalculateConfirmedWeight(InboundOrderEntity inbound, long operatorId) {
|
||||
List<InboundWeightResultEntity> confirmedWeights = repository
|
||||
.listWeights(inbound.getTenantId(), inbound.getId()).stream()
|
||||
.filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))
|
||||
.toList();
|
||||
BigDecimal total = confirmedWeights.stream()
|
||||
.map(InboundWeightResultEntity::getNetWeightKg)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add)
|
||||
.setScale(3, RoundingMode.HALF_UP);
|
||||
inbound.setActualGrossWeightKg(confirmedWeights.isEmpty() ? null : total);
|
||||
ConfirmedWeightTotals totals = confirmedWeightTotals(
|
||||
repository.listWeights(inbound.getTenantId(), inbound.getId()));
|
||||
inbound.setActualGrossWeightKg(totals.grossWeightKg());
|
||||
inbound.setActualNetWeightKg(totals.netWeightKg());
|
||||
if ("WAIT_MEASURE".equals(inbound.getProcessStatus())) {
|
||||
inbound.setProcessStatus("MEASURING");
|
||||
}
|
||||
@@ -337,13 +339,31 @@ public class InboundMeasurementService {
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal confirmedWeightForSubmission(List<InboundWeightResultEntity> weights) {
|
||||
BigDecimal total = weights.stream()
|
||||
private ConfirmedWeightTotals confirmedWeightTotals(List<InboundWeightResultEntity> weights) {
|
||||
List<InboundWeightResultEntity> confirmed = weights.stream()
|
||||
.filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))
|
||||
.toList();
|
||||
if (confirmed.isEmpty()) {
|
||||
return new ConfirmedWeightTotals(null, null, 0);
|
||||
}
|
||||
if (confirmed.stream().anyMatch(weight -> !isPositive(weight.getGrossWeightKg())
|
||||
|| !isPositive(weight.getNetWeightKg()))) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"已确认称重结果缺少有效毛重或净重,请重新确认后再继续");
|
||||
}
|
||||
BigDecimal grossWeightKg = confirmed.stream()
|
||||
.map(InboundWeightResultEntity::getGrossWeightKg)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add)
|
||||
.setScale(3, RoundingMode.HALF_UP);
|
||||
BigDecimal netWeightKg = confirmed.stream()
|
||||
.map(InboundWeightResultEntity::getNetWeightKg)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(3, RoundingMode.HALF_UP);
|
||||
return isPositive(total) ? total : null;
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add)
|
||||
.setScale(3, RoundingMode.HALF_UP);
|
||||
return new ConfirmedWeightTotals(grossWeightKg, netWeightKg, confirmed.size());
|
||||
}
|
||||
|
||||
private record ConfirmedWeightTotals(
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, int palletCount) {
|
||||
}
|
||||
|
||||
private void requireCompleteAdjustment(BigDecimal value, String unit, String reason) {
|
||||
@@ -353,6 +373,23 @@ public class InboundMeasurementService {
|
||||
}
|
||||
}
|
||||
|
||||
private void requireWeightEditableInbound(InboundOrderEntity inbound) {
|
||||
if (!WEIGHT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "当前入库状态不允许修改称重结果");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireWeightConfirmableInbound(InboundOrderEntity inbound, boolean hasAdjustment) {
|
||||
if (WEIGHT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) {
|
||||
return;
|
||||
}
|
||||
if ("MEASURE_PENDING".equals(inbound.getProcessStatus()) && hasAdjustment) {
|
||||
return;
|
||||
}
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"当前入库状态不允许确认称重结果,待审核状态仅允许人工修正并确认");
|
||||
}
|
||||
|
||||
private void insertRevision(InboundWeightResultEntity weight, int revisionNo, String action,
|
||||
BigDecimal beforeValue, String beforeUnit,
|
||||
BigDecimal afterValue, String afterUnit,
|
||||
@@ -380,7 +417,7 @@ public class InboundMeasurementService {
|
||||
private InboundMeasurementResponses.Detail toDetail(
|
||||
InboundOrderEntity inbound, InboundMeasurementEntity measurement,
|
||||
List<InboundMeasurementItemEntity> items, List<InboundWeightResultEntity> weights,
|
||||
MeasureReviewRecordEntity review) {
|
||||
MeasureReviewRecordEntity review, OrderCargoClient.MeasurementDeclarationSnapshot declaration) {
|
||||
return new InboundMeasurementResponses.Detail(
|
||||
inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(),
|
||||
inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(),
|
||||
@@ -391,18 +428,58 @@ public class InboundMeasurementService {
|
||||
measurement == null ? null : measurement.getStatus(),
|
||||
inbound.getVersion(),
|
||||
measurement == null ? null : measurement.getVersion(),
|
||||
measurement == null ? null : measurement.getOriginalDocumentNo(),
|
||||
measurement == null ? null : measurement.getOriginalGrossWeightKg(),
|
||||
measurement == null ? null : measurement.getOriginalVolumeCbm(),
|
||||
measurement != null && StringUtils.hasText(measurement.getOriginalDocumentNo())
|
||||
? measurement.getOriginalDocumentNo() : declaration == null ? null : declaration.referenceId(),
|
||||
measurement != null && measurement.getOriginalGrossWeightKg() != null
|
||||
? measurement.getOriginalGrossWeightKg() : declaration == null ? null : declaration.originalGrossWeightKg(),
|
||||
measurement != null && measurement.getOriginalVolumeCbm() != null
|
||||
? measurement.getOriginalVolumeCbm() : declaration == null ? null : declaration.originalVolumeCbm(),
|
||||
inbound.getTotalPackageCount(),
|
||||
measurement == null ? inbound.getTotalPackageCount() : measurement.getTotalPackageCount(),
|
||||
inbound.getActualGrossWeightKg(),
|
||||
inbound.getActualNetWeightKg(),
|
||||
measurement == null ? inbound.getActualVolumeCbm() : measurement.getTotalVolumeCbm(),
|
||||
measurement == null ? inbound.getVolumeWeightKg() : measurement.getVolumeWeightKg(),
|
||||
measurement == null ? null : measurement.getActualGoodsName(),
|
||||
measurement != null && StringUtils.hasText(measurement.getActualGoodsName())
|
||||
? measurement.getActualGoodsName() : declaration == null ? null : declaration.defaultActualGoodsName(),
|
||||
items.stream().map(this::toItem).toList(), weights.stream().map(this::toWeight).toList());
|
||||
}
|
||||
|
||||
private boolean needsDeclarationFallback(InboundMeasurementEntity measurement) {
|
||||
return measurement == null || !StringUtils.hasText(measurement.getOriginalDocumentNo())
|
||||
|| measurement.getOriginalGrossWeightKg() == null || measurement.getOriginalVolumeCbm() == null
|
||||
|| !StringUtils.hasText(measurement.getActualGoodsName());
|
||||
}
|
||||
|
||||
private OrderCargoClient.MeasurementDeclarationSnapshot loadDeclaration(InboundOrderEntity inbound) {
|
||||
if (inbound.getOrderId() == null || inbound.getOrderId() <= 0) {
|
||||
throw new BizException(ResultCode.CONFLICT, "入库单未关联有效订单,无法读取客户申报数据");
|
||||
}
|
||||
try {
|
||||
ApiResponse<OrderCargoClient.MeasurementDeclarationSnapshot> response = orderCargoClient
|
||||
.measurementDeclaration(internalToken, inbound.getTenantId(), inbound.getOrderId());
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
String message = response == null || !StringUtils.hasText(response.message())
|
||||
? "客户订单申报数据查询失败" : response.message();
|
||||
throw new BizException(response == null ? ResultCode.SERVICE_UNAVAILABLE
|
||||
: ResultCode.of(response.code()), message);
|
||||
}
|
||||
return response.data();
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法读取客户申报数据");
|
||||
}
|
||||
}
|
||||
|
||||
private String defaultGoodsName(OrderCargoClient.MeasurementDeclarationSnapshot declaration) {
|
||||
String value = declaration.defaultActualGoodsName();
|
||||
if (!StringUtils.hasText(value)) return null;
|
||||
if (value.length() > 255) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"客户货物品名汇总超过255字符,请填写精简后的实际品名");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item) {
|
||||
return new InboundMeasurementResponses.Item(
|
||||
item.getId(), item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
|
||||
|
||||
@@ -28,6 +28,8 @@ public class MeasurementReviewService {
|
||||
@Resource
|
||||
private InboundWorkflowRepository repository;
|
||||
@Resource
|
||||
private com.baiyue.firstleg.repository.InboundMeasurementRepository measurements;
|
||||
@Resource
|
||||
private InboundIdempotencyService idempotency;
|
||||
@Resource
|
||||
private InboundProperties properties;
|
||||
@@ -78,12 +80,30 @@ public class MeasurementReviewService {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"人工确认箱数合计与入库单总箱数不一致,不能审核通过");
|
||||
}
|
||||
BigDecimal weight = order.getActualGrossWeightKg();
|
||||
var weights = measurements.listWeights(tenantId, inboundId);
|
||||
if (weights.isEmpty() || weights.stream().anyMatch(weight ->
|
||||
!"CONFIRMED".equals(weight.getRecognitionStatus())
|
||||
|| weight.getGrossWeightKg() == null
|
||||
|| weight.getGrossWeightKg().signum() <= 0
|
||||
|| weight.getNetWeightKg() == null
|
||||
|| weight.getNetWeightKg().signum() <= 0)) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"所有称重结果必须已确认且包含大于零的毛重和货物净重,才能审核通过");
|
||||
}
|
||||
BigDecimal grossWeight = weights.stream()
|
||||
.map(com.baiyue.firstleg.entity.InboundWeightResultEntity::getGrossWeightKg)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal netWeight = weights.stream()
|
||||
.map(com.baiyue.firstleg.entity.InboundWeightResultEntity::getNetWeightKg)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal volume = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getTotalVolumeCbm)
|
||||
.filter(java.util.Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
if (weight == null || weight.signum() <= 0 || volume.signum() <= 0) {
|
||||
if (order.getActualGrossWeightKg() == null || order.getActualNetWeightKg() == null
|
||||
|| order.getActualGrossWeightKg().compareTo(grossWeight) != 0
|
||||
|| order.getActualNetWeightKg().compareTo(netWeight) != 0
|
||||
|| volume.signum() <= 0) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"审核通过的测量快照必须包含大于零的货物总重量和体积");
|
||||
"审核通过前称重毛重、货物净重汇总和体积必须完整且一致");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ public class MerchantInboundBillingService {
|
||||
var summary = new MerchantInboundBillingResponse.MeasurementSummary(
|
||||
measurement.measurementId(), measurement.sourceType(), measurement.measurementStatus(),
|
||||
measurement.actualPackageCount(), measurement.actualGrossWeightKg(),
|
||||
measurement.actualNetWeightKg(),
|
||||
measurement.actualVolumeCbm(), measurement.volumeWeightKg(), measurement.actualGoodsName());
|
||||
var measurementImages = measurement.items().stream()
|
||||
.filter(item -> item.intelligentImageFileId() != null)
|
||||
|
||||
@@ -7,7 +7,7 @@ import com.baiyue.firstleg.dto.UnifiedInboundDetail;
|
||||
import com.baiyue.firstleg.dto.UnifiedInboundQuery;
|
||||
import com.baiyue.firstleg.dto.UnifiedInboundSummary;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
|
||||
import com.baiyue.firstleg.dto.CurrentContainerTransport;
|
||||
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
|
||||
import com.baiyue.firstleg.repository.InboundOrderRepository;
|
||||
import com.baiyue.firstleg.repository.UnifiedInboundReadRepository;
|
||||
@@ -75,7 +75,7 @@ public class UnifiedInboundQueryService {
|
||||
evidence = operations.findEvidence(tenantId, inbound.getId());
|
||||
deliveryAt = deliveryAt(detail.statusHistory());
|
||||
}
|
||||
InboundTransportSnapshotEntity transport = includePhaseThree
|
||||
CurrentContainerTransport transport = includePhaseThree
|
||||
? operations.findTransport(tenantId, inbound.getId()) : null;
|
||||
return new UnifiedInboundDetail(summary(inbound, detail, preload, pick, evidence, deliveryAt,
|
||||
transport, null, null), timeline);
|
||||
@@ -105,7 +105,7 @@ public class UnifiedInboundQueryService {
|
||||
com.baiyue.firstleg.split.entity.PreloadPlanEntity preload,
|
||||
com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity pick,
|
||||
List<LoadingEvidenceEntity> evidence, LocalDateTime deliveryAt,
|
||||
InboundTransportSnapshotEntity transport, String locationCodes,
|
||||
CurrentContainerTransport transport, String locationCodes,
|
||||
com.baiyue.firstleg.entity.InboundPickupSnapshotEntity pickup) {
|
||||
String mainStatus = mainStatus(inbound.getProcessStatus());
|
||||
List<LoadingEvidenceEntity> activeEvidence = evidence.stream()
|
||||
@@ -124,13 +124,22 @@ public class UnifiedInboundQueryService {
|
||||
? new UnifiedInboundSummary.DeliverySummary(
|
||||
"DELIVERED".equals(mainStatus) ? "COMPLETED" : "PENDING", deliveryAt,
|
||||
inbound.getExpressTrackingNo()) : null;
|
||||
var inboundSummary = detail == null
|
||||
? com.baiyue.firstleg.dto.InboundOrderSummary.from(inbound, transport, pickup, locationCodes) : detail.summary();
|
||||
var baseSummary = detail == null ? null : detail.summary();
|
||||
var inboundSummary = com.baiyue.firstleg.dto.InboundOrderSummary.from(inbound, transport,
|
||||
pickup == null && baseSummary != null && baseSummary.requestedPickupAt() != null
|
||||
? pickupSnapshot(baseSummary.requestedPickupAt()) : pickup,
|
||||
locationCodes == null && baseSummary != null ? baseSummary.locationCodes() : locationCodes);
|
||||
return new UnifiedInboundSummary(inboundSummary, mainStatus,
|
||||
nextAction(mainStatus, inbound.getProcessStatus(), preload == null ? null : preload.getStatus()),
|
||||
preloadSummary, loadingSummary, deliverySummary);
|
||||
}
|
||||
|
||||
private com.baiyue.firstleg.entity.InboundPickupSnapshotEntity pickupSnapshot(LocalDateTime pickupAt) {
|
||||
var snapshot = new com.baiyue.firstleg.entity.InboundPickupSnapshotEntity();
|
||||
snapshot.setPickupAt(pickupAt);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private LocalDateTime deliveryAt(List<InboundOrderDetail.StatusHistorySummary> history) {
|
||||
return history.stream()
|
||||
.filter(item -> "COMPLETE_DELIVERY".equals(item.action()) || "DELIVERED".equals(item.toStatus()))
|
||||
|
||||
@@ -42,13 +42,13 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
|
||||
hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货',
|
||||
current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照',
|
||||
total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数',
|
||||
actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '下游货物实际毛重kg,汇总已确认称重净重',
|
||||
actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '已确认称重图片含托盘毛重汇总kg',
|
||||
chargeable_weight_kg DECIMAL(18,3) NULL COMMENT '当前费用快照的计费重量kg',
|
||||
actual_volume_cbm DECIMAL(18,4) NULL COMMENT '确认后的实际总体积m3',
|
||||
chargeable_volume_cbm DECIMAL(18,4) NULL COMMENT '当前费用快照的计费体积m3',
|
||||
pallet_count INT UNSIGNED NULL COMMENT '已分配总托盘数快照',
|
||||
volume_weight_kg DECIMAL(18,3) NULL COMMENT '总材积重kg',
|
||||
actual_net_weight_kg DECIMAL(18,3) NULL COMMENT '总净重kg',
|
||||
actual_net_weight_kg DECIMAL(18,3) NULL COMMENT '已确认称重图片扣除托盘皮重后的货物净重汇总kg',
|
||||
measure_completed_at DATETIME(3) NULL COMMENT '测量完成时间',
|
||||
inbounded_at DATETIME(3) NULL COMMENT '入库完成时间',
|
||||
express_company_code VARCHAR(64) NULL COMMENT '快递公司编码快照',
|
||||
|
||||
@@ -566,8 +566,8 @@ paths:
|
||||
由稳定的 item.id(measurementItemId)与 intelligentImageFileId 关联;称重图片只随
|
||||
weightResults 返回,由稳定的 weightResult.id 与 fileId 关联,并携带确认状态及服务端确认的
|
||||
grossWeightKg/netWeightKg。两类证据不得合并为无明细归属的统一图片列表。
|
||||
actualGrossWeightKg 是服务端权威的货物总重量;存在已确认称重结果时为所有已确认
|
||||
netWeightKg 之和,消费者不得根据图片或 OCR 值自行重算。
|
||||
actualGrossWeightKg 是服务端确认后的实测毛重,actualNetWeightKg 是扣除托盘皮重后的货物净重;
|
||||
两者语义独立,消费者不得互相回退,也不得根据图片或 OCR 值自行重算。
|
||||
parameters:
|
||||
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
@@ -578,6 +578,23 @@ paths:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/InboundMeasurementDetailEnvelope'}
|
||||
'403': {description: 无入库测量查看权限}
|
||||
/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/draft:
|
||||
put:
|
||||
summary: 保存测量草稿
|
||||
description: >-
|
||||
原单据号码、申报毛重和申报体积由服务端从客户订单生成快照,客户端不得提交覆盖;
|
||||
actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。
|
||||
parameters:
|
||||
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/InboundMeasurementSaveDraftRequest'}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
'403': {description: 无测量编辑权限}
|
||||
/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans:
|
||||
post:
|
||||
summary: 平台模拟单箱设备扫描
|
||||
@@ -697,7 +714,7 @@ paths:
|
||||
platform:firstleg:inbound:weight:edit 权限。修改与确认在同一事务内完成,保留 OCR 原值,
|
||||
分别写入 CORRECT 和 CONFIRM 修订记录。输入表示含托盘毛重;服务端统一换算为 KG,
|
||||
使用创建时固化的托盘皮重计算 netWeightKg,且毛重必须大于皮重。
|
||||
入库单 actualGrossWeightKg 明确保留为下游货物实际毛重字段,值汇总所有已确认 netWeightKg。
|
||||
入库单 actualGrossWeightKg 为实测毛重;actualNetWeightKg 为扣除托盘皮重后的货物净重,两者不得混用。
|
||||
parameters:
|
||||
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
@@ -749,7 +766,7 @@ paths:
|
||||
summary: 删除称重图片及重量结果
|
||||
description: >-
|
||||
需要 platform:firstleg:inbound:weight:edit 权限;仅允许在可编辑的测量状态删除。
|
||||
删除已确认图片后重新汇总剩余已确认图片的净重,删除最后一张时 actualGrossWeightKg 恢复为空。
|
||||
删除已确认图片后由生产者分别重算剩余确认称重的毛重、净重;消费端不得用净重填充毛重。
|
||||
parameters:
|
||||
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: version, in: query, required: true, schema: {type: integer, format: int32, minimum: 0}}
|
||||
@@ -779,15 +796,6 @@ paths:
|
||||
reason: {type: string, maxLength: 1000}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断;审核成功 data.processStatus 固定为 WAIT_SPLIT,initialPricingSucceeded 表示是否同时生成初算费用,初算失败时 feeStatus=UNPRICED 且主流程仍成功}
|
||||
/api/platform/v1/inbounds/{inboundId}/measurements/reject:
|
||||
post:
|
||||
summary: 驳回测量
|
||||
description: 需要 firstleg:inbound:measurement-review 权限;只退回测量数据修改,已确认托盘和库位占用保持不动。
|
||||
parameters:
|
||||
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: Idempotency-Key, in: header, required: true, schema: {type: string}}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败}
|
||||
/api/platform/v1/inbounds/{inboundId}/measurements/submit:
|
||||
post:
|
||||
summary: 提交测量完成
|
||||
@@ -811,15 +819,6 @@ paths:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/MerchantInboundBillingEnvelope'}
|
||||
'403': {description: 非当前客户或无订单查看权限}
|
||||
/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:
|
||||
post:
|
||||
summary: 测量异常放行
|
||||
description: 需要 firstleg:inbound:exception-release 权限
|
||||
parameters:
|
||||
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: Idempotency-Key, in: header, required: true, schema: {type: string}}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败}
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
@@ -1035,8 +1034,8 @@ components:
|
||||
currentHoldReason: {type: string, nullable: true}
|
||||
totalPackageCount: {type: integer, nullable: true}
|
||||
locationCodes: {type: string, nullable: true}
|
||||
actualGrossWeightKg: {type: number, nullable: true, multipleOf: 0.001, description: 服务端权威货物总重量 KG;存在已确认称重图片时为全部 CONFIRMED netWeightKg 之和,消费者不得从图片重算}
|
||||
actualNetWeightKg: {type: number, nullable: true}
|
||||
actualGrossWeightKg: {type: number, nullable: true, multipleOf: 0.001, description: 服务端确认后的实测毛重 KG;只读取 fl_inbound_order.actual_gross_weight_kg,不以净重回退}
|
||||
actualNetWeightKg: {type: number, nullable: true, multipleOf: 0.001, description: 服务端确认后的货物净重 KG;只读取 fl_inbound_order.actual_net_weight_kg,不以毛重回退}
|
||||
actualVolumeCbm: {type: number, nullable: true}
|
||||
volumeWeightKg: {type: number, nullable: true}
|
||||
chargeableWeightKg: {type: number, nullable: true}
|
||||
@@ -1050,8 +1049,10 @@ components:
|
||||
cabinetNo: {type: string, nullable: true}
|
||||
vesselName: {type: string, nullable: true}
|
||||
voyageNo: {type: string, nullable: true}
|
||||
sailingAt: {type: string, format: date-time, nullable: true}
|
||||
portArrivalAt: {type: string, format: date-time, nullable: true}
|
||||
estimatedSailingAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次预计开船时间}
|
||||
actualSailingAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次实际开船时间}
|
||||
estimatedPortArrivalAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次预计到港时间}
|
||||
actualPortArrivalAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次实际到港时间}
|
||||
expressTrackingNo: {type: string, nullable: true}
|
||||
pickupMethod: {type: string, enum: [SELF_EXPRESS, PICKUP, SELF_TRUCK], nullable: true}
|
||||
requestedPickupAt: {type: string, format: date-time, nullable: true, description: 客户要求平台上门提货的预约时间}
|
||||
@@ -1286,9 +1287,9 @@ components:
|
||||
measurementStatus: {type: string, nullable: true}
|
||||
inboundVersion: {type: integer, minimum: 0}
|
||||
version: {type: integer, minimum: 0, nullable: true}
|
||||
originalDocumentNo: {type: string, nullable: true}
|
||||
originalGrossWeightKg: {type: number, format: double, nullable: true}
|
||||
originalVolumeCbm: {type: number, format: double, nullable: true}
|
||||
originalDocumentNo: {type: string, nullable: true, description: 客户订单 referenceId 的服务端快照;旧空快照按订单事实回退}
|
||||
originalGrossWeightKg: {type: number, format: double, nullable: true, description: 客户订单有效货物行 grossWeightKg 合计的服务端快照}
|
||||
originalVolumeCbm: {type: number, format: double, nullable: true, description: 客户订单有效货物行 volumeCbm 合计的服务端快照}
|
||||
originalPackageCount: {type: integer, nullable: true}
|
||||
actualPackageCount: {type: integer, nullable: true}
|
||||
actualGrossWeightKg:
|
||||
@@ -1296,10 +1297,16 @@ components:
|
||||
format: double
|
||||
nullable: true
|
||||
multipleOf: 0.001
|
||||
description: 服务端权威货物总重量 KG;存在已确认称重结果时为所有 CONFIRMED netWeightKg 之和
|
||||
description: 全部 CONFIRMED 称重结果 grossWeightKg 之和,含托盘毛重 KG;无确认结果时为空
|
||||
actualNetWeightKg:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
multipleOf: 0.001
|
||||
description: 全部 CONFIRMED 称重结果 netWeightKg 之和,扣除托盘皮重后的货物净重 KG;无确认结果时为空,审核与计费以此为准
|
||||
actualVolumeCbm: {type: number, format: double, nullable: true}
|
||||
volumeWeightKg: {type: number, format: double, nullable: true}
|
||||
actualGoodsName: {type: string, nullable: true}
|
||||
actualGoodsName: {type: string, nullable: true, description: 默认由客户货物品名去重汇总,测量阶段允许人工修正}
|
||||
items:
|
||||
type: array
|
||||
description: DIMENSION 尺寸测量明细及其图片证据
|
||||
@@ -1308,6 +1315,38 @@ components:
|
||||
type: array
|
||||
description: WEIGHT 称重结果及其图片、确认状态和服务端重量事实
|
||||
items: {$ref: '#/components/schemas/InboundWeightResult'}
|
||||
InboundMeasurementSaveDraftRequest:
|
||||
type: object
|
||||
required: [version, items]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
measurementId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
version: {type: integer, minimum: 0}
|
||||
actualGoodsName: {type: string, maxLength: 255, nullable: true}
|
||||
items:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 500
|
||||
items: {$ref: '#/components/schemas/InboundMeasurementItemRequest'}
|
||||
InboundMeasurementItemRequest:
|
||||
type: object
|
||||
required: [inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm, unitWeightKg]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
inboundDetailId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
itemNo: {type: string, minLength: 1, maxLength: 32}
|
||||
boxSpecification: {type: string, minLength: 1, maxLength: 64}
|
||||
packageCount: {type: integer, minimum: 1, maximum: 1000000}
|
||||
lengthCm: {type: number, minimum: 0.001}
|
||||
widthCm: {type: number, minimum: 0.001}
|
||||
heightCm: {type: number, minimum: 0.001}
|
||||
unitWeightKg: {type: number, minimum: 0.001}
|
||||
intelligentImageFileId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
overLimitWarning: {type: string, maxLength: 500, nullable: true}
|
||||
outerLengthCm: {type: number, minimum: 0, nullable: true}
|
||||
outerWidthCm: {type: number, minimum: 0, nullable: true}
|
||||
outerHeightCm: {type: number, minimum: 0, nullable: true}
|
||||
remark: {type: string, maxLength: 500, nullable: true}
|
||||
InboundMeasurementDetailEnvelope:
|
||||
type: object
|
||||
required: [code, message]
|
||||
|
||||
+16
-4
@@ -23,21 +23,33 @@ class InboundMeasurementEvidenceOpenApiContractTest {
|
||||
|
||||
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
|
||||
Map<String, Object> detailProperties = properties(schemas, "InboundMeasurementDetail");
|
||||
assertThat(detailProperties).containsKeys("actualGrossWeightKg", "items", "weightResults");
|
||||
assertThat(detailProperties).containsKeys(
|
||||
"actualGrossWeightKg", "actualNetWeightKg", "items", "weightResults");
|
||||
assertThat(stringMap(detailProperties.get("items")))
|
||||
.containsEntry("description", "DIMENSION 尺寸测量明细及其图片证据");
|
||||
assertThat(stringMap(detailProperties.get("weightResults")))
|
||||
.containsEntry("description", "WEIGHT 称重结果及其图片、确认状态和服务端重量事实");
|
||||
assertThat(detailProperties).doesNotContainKey("images");
|
||||
Map<String, Object> draftProperties = properties(schemas, "InboundMeasurementSaveDraftRequest");
|
||||
assertThat(draftProperties).containsKeys("measurementId", "version", "actualGoodsName", "items")
|
||||
.doesNotContainKeys("originalDocumentNo", "originalGrossWeightKg", "originalVolumeCbm");
|
||||
assertThat(paths).containsKey(
|
||||
"/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/draft");
|
||||
assertThat(stringMap(stringMap(draftProperties.get("items")).get("items")))
|
||||
.containsEntry("$ref", "#/components/schemas/InboundMeasurementItemRequest");
|
||||
assertThat(properties(schemas, "InboundMeasurementItemRequest")).containsKeys(
|
||||
"inboundDetailId", "itemNo", "boxSpecification", "packageCount", "lengthCm",
|
||||
"widthCm", "heightCm", "unitWeightKg", "intelligentImageFileId", "remark");
|
||||
|
||||
Map<String, Object> itemProperties = properties(schemas, "InboundMeasurementItem");
|
||||
assertThat(itemProperties).containsKeys("id", "intelligentImageFileId");
|
||||
Map<String, Object> weightProperties = properties(schemas, "InboundWeightResult");
|
||||
assertThat(weightProperties).containsKeys(
|
||||
"id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt");
|
||||
assertThat(stringMap(properties(schemas, "UnifiedInboundOrderSummary")
|
||||
.get("actualGrossWeightKg")).get("description").toString())
|
||||
.contains("服务端权威货物总重量", "CONFIRMED netWeightKg", "不得从图片重算");
|
||||
assertThat(stringMap(detailProperties.get("actualGrossWeightKg")).get("description").toString())
|
||||
.contains("CONFIRMED", "grossWeightKg", "含托盘毛重");
|
||||
assertThat(stringMap(detailProperties.get("actualNetWeightKg")).get("description").toString())
|
||||
.contains("CONFIRMED", "netWeightKg", "审核与计费以此为准");
|
||||
}
|
||||
|
||||
private static Map<String, Object> properties(Map<String, Object> schemas, String schemaName) {
|
||||
|
||||
+3
-8
@@ -1,7 +1,6 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
|
||||
import com.baiyue.firstleg.dto.MeasurementApprovalResponse;
|
||||
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
|
||||
import com.baiyue.firstleg.service.InitialInboundPricingService;
|
||||
@@ -24,14 +23,10 @@ import static org.mockito.Mockito.when;
|
||||
@DisplayName("入库测量审核控制器契约")
|
||||
class InboundMeasurementReviewContractTest {
|
||||
@Test
|
||||
@DisplayName("审核、放行和历史接口声明独立按钮权限")
|
||||
@DisplayName("审核和历史接口声明独立按钮权限")
|
||||
void shouldDeclareButtonPermissions() throws Exception {
|
||||
assertAuthority("approve", "hasAuthority('firstleg:inbound:measurement-review')",
|
||||
AuthenticatedUser.class, long.class, long.class, String.class, MeasurementReviewRequest.class);
|
||||
assertAuthority("reject", "hasAuthority('firstleg:inbound:measurement-review')",
|
||||
AuthenticatedUser.class, long.class, long.class, String.class, MeasurementReviewRequest.class);
|
||||
assertAuthority("exceptionRelease", "hasAuthority('firstleg:inbound:exception-release')",
|
||||
AuthenticatedUser.class, long.class, long.class, String.class, ExceptionReleaseRequest.class);
|
||||
assertAuthority("history", "hasAuthority('firstleg:inbound:measurement-review-history')",
|
||||
AuthenticatedUser.class, long.class, long.class, long.class, long.class);
|
||||
}
|
||||
@@ -43,14 +38,14 @@ class InboundMeasurementReviewContractTest {
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(openapi)
|
||||
.contains("/api/platform/v1/inbounds/{inboundId}/measurements/approve:")
|
||||
.contains("/api/platform/v1/inbounds/{inboundId}/measurements/reject:")
|
||||
.contains("firstleg:inbound:exception-release")
|
||||
.contains("Idempotency-Key")
|
||||
.contains("初算异常只返回非阻断提示")
|
||||
.contains("data.processStatus 固定为 WAIT_SPLIT")
|
||||
.contains("feeStatus=UNPRICED 且主流程仍成功")
|
||||
.contains("name: inboundId")
|
||||
.contains("schema: {type: string, pattern:")
|
||||
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/reject:")
|
||||
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:")
|
||||
.doesNotContain("待客户确认");
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -51,7 +51,9 @@ class InboundOrderControllerContractTest {
|
||||
.contains("required: false")
|
||||
.contains("InboundOrderSummary:")
|
||||
.contains("required: [id, tenantId]")
|
||||
.contains("actualNetWeightKg: {type: number, nullable: true}")
|
||||
.contains("actualNetWeightKg: {type: number, nullable: true")
|
||||
.contains("只读取 fl_inbound_order.actual_gross_weight_kg,不以净重回退")
|
||||
.contains("只读取 fl_inbound_order.actual_net_weight_kg,不以毛重回退")
|
||||
.contains("chargeableVolumeCbm: {type: number, nullable: true}")
|
||||
.contains("measureCompletedAt: {type: string, format: date-time, nullable: true}")
|
||||
.contains("pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]")
|
||||
|
||||
+21
@@ -2,17 +2,38 @@ package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.RecordComponent;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@DisplayName("平台入库测量接口权限契约")
|
||||
class PlatformInboundMeasurementControllerContractTest {
|
||||
@Test
|
||||
void draftRequestOnlyAcceptsEditableGoodsNameAndMeasurementItems() {
|
||||
assertThat(Arrays.stream(InboundMeasurementRequests.SaveDraftRequest.class.getRecordComponents())
|
||||
.map(RecordComponent::getName)).containsExactly(
|
||||
"measurementId", "version", "actualGoodsName", "items");
|
||||
}
|
||||
|
||||
@Test
|
||||
void draftRequestRejectsRemovedDeclarationFieldsAtDeserialization() {
|
||||
String json = """
|
||||
{"version":0,"originalGrossWeightKg":2000,"actualGoodsName":"货物","items":[]}
|
||||
""";
|
||||
assertThatThrownBy(() -> new ObjectMapper().readValue(
|
||||
json, InboundMeasurementRequests.SaveDraftRequest.class))
|
||||
.hasMessageContaining("originalGrossWeightKg");
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyMeasurementOperationUsesDedicatedButtonPermission() throws Exception {
|
||||
assertAuthority("detail", "hasAuthority('platform:firstleg:inbound:measure:view')",
|
||||
|
||||
+2
-1
@@ -41,7 +41,8 @@ class UnifiedInboundOpenApiContractTest {
|
||||
"holdFlag", "actualGrossWeightKg", "actualNetWeightKg", "actualVolumeCbm",
|
||||
"volumeWeightKg", "chargeableWeightKg", "chargeableVolumeCbm", "palletCount",
|
||||
"inboundedAt", "requestedPickupAt", "expressCompanyCode", "expressCompanyName", "containerNo",
|
||||
"cabinetNo", "sailingAt", "portArrivalAt", "createdByName", "createdAt", "updatedAt");
|
||||
"cabinetNo", "estimatedSailingAt", "actualSailingAt", "estimatedPortArrivalAt",
|
||||
"actualPortArrivalAt", "createdByName", "createdAt", "updatedAt");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class InboundOrderWeightSemanticsTest {
|
||||
@Test
|
||||
void summaryKeepsGrossAndNetWeightAsIndependentDatabaseFacts() {
|
||||
InboundOrderEntity inbound = new InboundOrderEntity();
|
||||
inbound.setActualGrossWeightKg(new BigDecimal("1787.500"));
|
||||
inbound.setActualNetWeightKg(new BigDecimal("1747.500"));
|
||||
|
||||
InboundOrderSummary result = InboundOrderSummary.from(
|
||||
inbound, (CurrentContainerTransport) null, null, null);
|
||||
|
||||
assertThat(result.actualGrossWeightKg()).isEqualByComparingTo("1787.500");
|
||||
assertThat(result.actualNetWeightKg()).isEqualByComparingTo("1747.500");
|
||||
}
|
||||
|
||||
@Test
|
||||
void summaryDoesNotFallbackBetweenGrossAndNetWeight() {
|
||||
InboundOrderEntity inbound = new InboundOrderEntity();
|
||||
inbound.setActualGrossWeightKg(new BigDecimal("1787.500"));
|
||||
|
||||
InboundOrderSummary result = InboundOrderSummary.from(
|
||||
inbound, (CurrentContainerTransport) null, null, null);
|
||||
|
||||
assertThat(result.actualGrossWeightKg()).isEqualByComparingTo("1787.500");
|
||||
assertThat(result.actualNetWeightKg()).isNull();
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -23,13 +23,14 @@ class InboundOrderStatusSqlContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsConfirmedWeightNetSumAuthoritativeWhenMeasurementIsApproved() {
|
||||
void keepsConfirmedGrossAndNetSumsSeparateWhenMeasurementIsApproved() {
|
||||
assertThat(sql("approveMeasurement"))
|
||||
.contains("FROM fl_inbound_weight_result weight")
|
||||
.contains("weight.recognition_status = 'CONFIRMED'")
|
||||
.contains("weight.deleted = 0")
|
||||
.contains("SUM(weight.net_weight_kg)")
|
||||
.contains("ELSE (SELECT COALESCE(SUM(item.total_weight_kg), 0)");
|
||||
.contains("actual_gross_weight_kg = (SELECT SUM(weight.gross_weight_kg)")
|
||||
.contains("actual_net_weight_kg = (SELECT SUM(weight.net_weight_kg)")
|
||||
.doesNotContain("item.total_weight_kg");
|
||||
}
|
||||
|
||||
private static String sql(String methodName) {
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.CurrentContainerTransport;
|
||||
import com.baiyue.firstleg.mapper.UnifiedInboundTransportMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
|
||||
class UnifiedInboundTransportRepositoryTest {
|
||||
private final UnifiedInboundTransportMapper mapper = mock(UnifiedInboundTransportMapper.class);
|
||||
private final UnifiedInboundReadRepository repository = repository();
|
||||
|
||||
@Test
|
||||
void batchKeepsParentChildAndDepartedUnitOnTheirOwnContainer() {
|
||||
var oldDeparted = fact(11L, 101L, 501L, "OLD", null, LocalDateTime.parse("2026-08-01T08:00"));
|
||||
var splitChild = fact(12L, 101L, 502L, "NEW", LocalDateTime.parse("2026-08-20T08:00"), null);
|
||||
when(mapper.findCurrentByInboundIds(anyList())).thenReturn(List.of(oldDeparted, splitChild));
|
||||
|
||||
var result = repository.findTransports(Map.of(11L, 101L, 12L, 101L));
|
||||
|
||||
assertThat(result.get(11L).containerBookingId()).isEqualTo(501L);
|
||||
assertThat(result.get(11L).actualSailingAt()).isEqualTo(LocalDateTime.parse("2026-08-01T08:00"));
|
||||
assertThat(result.get(12L).containerBookingId()).isEqualTo(502L);
|
||||
assertThat(result.get(12L).estimatedSailingAt()).isEqualTo(LocalDateTime.parse("2026-08-20T08:00"));
|
||||
verify(mapper).findCurrentByInboundIds(anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noContainerReturnsNoFact() {
|
||||
when(mapper.findCurrentByInboundIds(List.of(11L))).thenReturn(List.of());
|
||||
assertThat(repository.findTransports(Map.of(11L, 101L))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipleEffectiveContainersAreAnInvariantConflict() {
|
||||
when(mapper.findCurrentByInboundIds(List.of(11L))).thenReturn(List.of(
|
||||
fact(11L, 101L, 501L, "A", null, null), fact(11L, 101L, 502L, "B", null, null)));
|
||||
assertThatThrownBy(() -> repository.findTransports(Map.of(11L, 101L)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("多个有效柜次");
|
||||
}
|
||||
|
||||
@Test
|
||||
void filtersRowsOutsideInboundTenantOwnership() {
|
||||
when(mapper.findCurrentByInboundIds(List.of(11L))).thenReturn(List.of(
|
||||
fact(11L, 999L, 501L, "OTHER", null, null)));
|
||||
assertThat(repository.findTransports(Map.of(11L, 101L))).isEmpty();
|
||||
}
|
||||
|
||||
private UnifiedInboundReadRepository repository() {
|
||||
var value = new UnifiedInboundReadRepository();
|
||||
ReflectionTestUtils.setField(value, "transports", mapper);
|
||||
return value;
|
||||
}
|
||||
|
||||
private CurrentContainerTransport fact(Long inboundId, Long accountId, Long bookingId, String containerNo,
|
||||
LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt) {
|
||||
return new CurrentContainerTransport(inboundId, accountId, bookingId, containerNo, "CAB", "VESSEL", "V1",
|
||||
estimatedSailingAt, actualSailingAt, null, null);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.mapper.UnifiedInboundTransportMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class UnifiedInboundTransportSqlContractTest {
|
||||
@Test
|
||||
void queriesCurrentBusinessUnitContainersInOneBatchFromAuthoritativeBooking() throws Exception {
|
||||
String sql = String.join(" ", UnifiedInboundTransportMapper.class
|
||||
.getMethod("findCurrentByInboundIds", java.util.List.class).getAnnotation(Select.class).value());
|
||||
assertThat(sql).contains("fl_loading_plan_item", "i.deleted = 0",
|
||||
"p.plan_status IN ('LOADING', 'COMPLETED')", "fl_container_booking",
|
||||
"estimated_sailing_at", "actual_sailing_at", "estimated_port_arrival_at",
|
||||
"actual_port_arrival_at", "<foreach");
|
||||
assertThat(sql).doesNotContain("fl_inbound_transport_snapshot", "LIMIT 1");
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -275,7 +275,7 @@ class CartonMeasurementServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmedPackageCountUsesCartonWeightAsFallbackWhenNoConfirmedImageExists() {
|
||||
void confirmedPackageCountDoesNotUseCartonWeightAsWeightFallback() {
|
||||
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
|
||||
InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2);
|
||||
measurement.setTotalPackageCount(1);
|
||||
@@ -300,7 +300,8 @@ class CartonMeasurementServiceTest {
|
||||
service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 5);
|
||||
|
||||
assertThat(inbound.getTotalPackageCount()).isEqualTo(5);
|
||||
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("54.400");
|
||||
assertThat(inbound.getActualGrossWeightKg()).isNull();
|
||||
assertThat(inbound.getActualNetWeightKg()).isNull();
|
||||
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.7480");
|
||||
assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("124.916");
|
||||
verify(repository).updateInbound(inbound);
|
||||
@@ -309,7 +310,8 @@ class CartonMeasurementServiceTest {
|
||||
@Test
|
||||
void cartonMeasurementAfterConfirmedWeightKeepsNetWeightAndUpdatesVolume() {
|
||||
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
|
||||
inbound.setActualGrossWeightKg(new BigDecimal("11.500"));
|
||||
inbound.setActualGrossWeightKg(new BigDecimal("30.000"));
|
||||
inbound.setActualNetWeightKg(new BigDecimal("11.500"));
|
||||
InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2);
|
||||
measurement.setTotalPackageCount(1);
|
||||
measurement.setTotalVolumeCbm(new BigDecimal("0.0600"));
|
||||
@@ -323,6 +325,7 @@ class CartonMeasurementServiceTest {
|
||||
item.setCalculationRuleVersion("ACTUAL_DIMENSION_V1");
|
||||
InboundWeightResultEntity confirmedWeight = new InboundWeightResultEntity();
|
||||
confirmedWeight.setRecognitionStatus("CONFIRMED");
|
||||
confirmedWeight.setGrossWeightKg(new BigDecimal("30.000"));
|
||||
confirmedWeight.setNetWeightKg(new BigDecimal("11.500"));
|
||||
when(repository.findInbound(10001L, 70001L)).thenReturn(inbound);
|
||||
when(repository.findItem(10001L, 40001L)).thenReturn(item);
|
||||
@@ -335,7 +338,8 @@ class CartonMeasurementServiceTest {
|
||||
|
||||
service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 5);
|
||||
|
||||
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("11.500");
|
||||
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("30.000");
|
||||
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("11.500");
|
||||
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.7480");
|
||||
assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("124.916");
|
||||
verify(repository).updateInbound(inbound);
|
||||
|
||||
@@ -421,7 +421,8 @@ class FbaPricingServiceTest {
|
||||
value.setChannelCode("MATSON_TRUCK"); value.setCarrierId(31L); value.setCarrierName("船司A");
|
||||
value.setTaxMode("BUY_DECLARATION"); value.setPricingTaxMode("TAX_INCLUDED");
|
||||
value.setDestinationType("FBA"); value.setDestinationCode("ONT8");
|
||||
value.setActualGrossWeightKg(new BigDecimal(weight)); value.setActualVolumeCbm(new BigDecimal(volume));
|
||||
value.setActualGrossWeightKg(new BigDecimal(weight).add(new BigDecimal("8.000")));
|
||||
value.setActualNetWeightKg(new BigDecimal(weight)); value.setActualVolumeCbm(new BigDecimal(volume));
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -459,6 +459,7 @@ class FeeManagementServiceTest {
|
||||
inbound.setDestinationName("ONT8");
|
||||
inbound.setTotalPackageCount(10);
|
||||
inbound.setActualGrossWeightKg(new BigDecimal("10.5000"));
|
||||
inbound.setActualNetWeightKg(new BigDecimal("10.5000"));
|
||||
inbound.setActualVolumeCbm(new BigDecimal("1.250000"));
|
||||
inbound.setVolumeWeightKg(new BigDecimal("208.3333"));
|
||||
inbound.setChargeableWeightKg(new BigDecimal("208.3333"));
|
||||
|
||||
+76
-5
@@ -56,8 +56,12 @@ class InboundMeasurementServiceTest {
|
||||
|
||||
@BeforeEach
|
||||
void stubWarehousePalletSnapshots() {
|
||||
when(repository.updateInboundWeightSummary(any())).thenReturn(true);
|
||||
when(warehousePallets.palletSnapshots("internal-token", 100L)).thenReturn(
|
||||
ApiResponse.success(List.of(palletSnapshot())));
|
||||
when(orderCargoClient.measurementDeclaration("internal-token", 1L, 80L)).thenReturn(
|
||||
ApiResponse.success(new OrderCargoClient.MeasurementDeclarationSnapshot(
|
||||
"REF-50", new BigDecimal("2000"), new BigDecimal("20"), "123123")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -98,6 +102,7 @@ class InboundMeasurementServiceTest {
|
||||
void correctingConfirmedWeightImmediatelyRemovesItFromInboundTotal() {
|
||||
InboundOrderEntity inbound = inbound("MEASURING");
|
||||
inbound.setActualGrossWeightKg(new BigDecimal("12.500"));
|
||||
inbound.setActualNetWeightKg(new BigDecimal("12.500"));
|
||||
InboundWeightResultEntity weight = weight(20L, "12.500", "KG", "CONFIRMED", 4);
|
||||
weight.setGrossWeightKg(new BigDecimal("12.500"));
|
||||
weight.setNetWeightKg(new BigDecimal("12.500"));
|
||||
@@ -115,6 +120,7 @@ class InboundMeasurementServiceTest {
|
||||
assertThat(weight.getGrossWeightKg()).isNull();
|
||||
assertThat(weight.getNetWeightKg()).isNull();
|
||||
assertThat(inbound.getActualGrossWeightKg()).isNull();
|
||||
assertThat(inbound.getActualNetWeightKg()).isNull();
|
||||
verify(repository).updateInboundWeightSummary(inbound);
|
||||
}
|
||||
|
||||
@@ -134,13 +140,18 @@ class InboundMeasurementServiceTest {
|
||||
assertThat(result.recommendedPalletType()).isEqualTo("STANDARD");
|
||||
assertThat(result.inboundVersion()).isEqualTo(7);
|
||||
assertThat(result.version()).isEqualTo(4);
|
||||
assertThat(result.originalDocumentNo()).isEqualTo("REF-50");
|
||||
assertThat(result.originalGrossWeightKg()).isEqualByComparingTo("2000");
|
||||
assertThat(result.originalVolumeCbm()).isEqualByComparingTo("20");
|
||||
assertThat(result.actualGoodsName()).isEqualTo("123123");
|
||||
verify(repository, never()).updateInbound(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailKeepsDimensionAndWeightEvidenceOnTheirStableBusinessRecords() {
|
||||
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
|
||||
inbound.setActualGrossWeightKg(new BigDecimal("11.500"));
|
||||
inbound.setActualGrossWeightKg(new BigDecimal("30.000"));
|
||||
inbound.setActualNetWeightKg(new BigDecimal("11.500"));
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(30L);
|
||||
measurement.setVersion(4);
|
||||
@@ -161,7 +172,8 @@ class InboundMeasurementServiceTest {
|
||||
|
||||
InboundMeasurementResponses.Detail result = service.detail(1L, 10L);
|
||||
|
||||
assertThat(result.actualGrossWeightKg()).isEqualByComparingTo("11.500");
|
||||
assertThat(result.actualGrossWeightKg()).isEqualByComparingTo("30.000");
|
||||
assertThat(result.actualNetWeightKg()).isEqualByComparingTo("11.500");
|
||||
assertThat(result.items()).singleElement().satisfies(dimension -> {
|
||||
assertThat(dimension.id()).isEqualTo(40L);
|
||||
assertThat(dimension.intelligentImageFileId()).isEqualTo(140L);
|
||||
@@ -232,6 +244,7 @@ class InboundMeasurementServiceTest {
|
||||
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
|
||||
InboundWeightResultEntity current = weight(20L, "10.000", "LB", "PENDING_CONFIRM", 0);
|
||||
InboundWeightResultEntity previous = weight(21L, "2.000", "KG", "CONFIRMED", 1);
|
||||
previous.setGrossWeightKg(new BigDecimal("2.000"));
|
||||
previous.setNetWeightKg(new BigDecimal("2.000"));
|
||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
||||
when(repository.findWeight(1L, 10L, 20L)).thenReturn(current);
|
||||
@@ -246,6 +259,7 @@ class InboundMeasurementServiceTest {
|
||||
assertThat(response.grossWeightKg()).isEqualByComparingTo("4.536");
|
||||
assertThat(response.netWeightKg()).isEqualByComparingTo("4.536");
|
||||
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("6.536");
|
||||
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("6.536");
|
||||
assertThat(inbound.getProcessStatus()).isEqualTo("MEASURING");
|
||||
verify(repository).insertRevision(any(InboundWeightRevisionEntity.class));
|
||||
}
|
||||
@@ -268,7 +282,8 @@ class InboundMeasurementServiceTest {
|
||||
|
||||
assertThat(response.grossWeightKg()).isEqualByComparingTo("30.000");
|
||||
assertThat(response.netWeightKg()).isEqualByComparingTo("11.500");
|
||||
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("11.500");
|
||||
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("30.000");
|
||||
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("11.500");
|
||||
ArgumentCaptor<InboundWeightRevisionEntity> revision =
|
||||
ArgumentCaptor.forClass(InboundWeightRevisionEntity.class);
|
||||
verify(repository).insertRevision(revision.capture());
|
||||
@@ -276,6 +291,31 @@ class InboundMeasurementServiceTest {
|
||||
assertThat(revision.getValue().getNetWeightKg()).isEqualByComparingTo("11.500");
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmingFifthPalletSeparatelyAggregatesGrossAndCargoNetWeight() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
|
||||
InboundWeightResultEntity current = weight(20L, "357.500", "KG", "PENDING_CONFIRM", 0);
|
||||
current.setPalletTareWeightKg(new BigDecimal("8.000"));
|
||||
List<InboundWeightResultEntity> weights = new java.util.ArrayList<>();
|
||||
weights.add(current);
|
||||
for (long id = 21L; id <= 24L; id++) {
|
||||
InboundWeightResultEntity confirmed = weight(id, "357.500", "KG", "CONFIRMED", 1);
|
||||
confirmed.setGrossWeightKg(new BigDecimal("357.500"));
|
||||
confirmed.setNetWeightKg(new BigDecimal("349.500"));
|
||||
weights.add(confirmed);
|
||||
}
|
||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
||||
when(repository.findWeight(1L, 10L, 20L)).thenReturn(current);
|
||||
when(repository.updateWeight(current)).thenReturn(true);
|
||||
when(repository.listWeights(1L, 10L)).thenReturn(weights);
|
||||
|
||||
service.confirmWeightResult(1L, 10L, 20L, 9L, "操作员",
|
||||
new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null));
|
||||
|
||||
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("1787.500");
|
||||
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("1747.500");
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmingWeightRejectsGrossWeightNotAboveFrozenTare() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
|
||||
@@ -545,7 +585,7 @@ class InboundMeasurementServiceTest {
|
||||
|
||||
verify(repository).deleteWeight(1L, 10L, 20L, 1);
|
||||
verify(imageClient).delete("internal-token", 9L, 1L, 10L, 120L);
|
||||
verify(repository, never()).updateInboundWeightSummary(any());
|
||||
verify(repository).updateInboundWeightSummary(inbound);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -566,6 +606,7 @@ class InboundMeasurementServiceTest {
|
||||
service.deleteWeightResult(1L, 10L, 20L, 2, 9L);
|
||||
|
||||
assertThat(inbound.getActualGrossWeightKg()).isNull();
|
||||
assertThat(inbound.getActualNetWeightKg()).isNull();
|
||||
verify(repository).updateInboundWeightSummary(inbound);
|
||||
}
|
||||
|
||||
@@ -574,8 +615,10 @@ class InboundMeasurementServiceTest {
|
||||
InboundOrderEntity inbound = inbound("MEASURING");
|
||||
inbound.setActualGrossWeightKg(new BigDecimal("15.000"));
|
||||
InboundWeightResultEntity deleted = weight(20L, "10.000", "KG", "CONFIRMED", 2);
|
||||
deleted.setGrossWeightKg(new BigDecimal("10.000"));
|
||||
deleted.setNetWeightKg(new BigDecimal("10.000"));
|
||||
InboundWeightResultEntity remaining = weight(21L, "5.000", "KG", "CONFIRMED", 1);
|
||||
remaining.setGrossWeightKg(new BigDecimal("5.000"));
|
||||
remaining.setNetWeightKg(new BigDecimal("5.000"));
|
||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
||||
when(repository.findWeight(1L, 10L, 20L)).thenReturn(deleted);
|
||||
@@ -588,6 +631,7 @@ class InboundMeasurementServiceTest {
|
||||
service.deleteWeightResult(1L, 10L, 20L, 2, 9L);
|
||||
|
||||
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("5.000");
|
||||
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("5.000");
|
||||
verify(repository).updateInboundWeightSummary(inbound);
|
||||
}
|
||||
|
||||
@@ -622,6 +666,7 @@ class InboundMeasurementServiceTest {
|
||||
item.setPackageCount(3);
|
||||
item.setSourceImageFileId(500L);
|
||||
InboundWeightResultEntity weight = weight(20L, "20.000", "KG", "CONFIRMED", 1);
|
||||
weight.setGrossWeightKg(new BigDecimal("20.000"));
|
||||
weight.setNetWeightKg(new BigDecimal("20.000"));
|
||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
||||
when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement);
|
||||
@@ -638,6 +683,7 @@ class InboundMeasurementServiceTest {
|
||||
assertThat(inbound.getMeasureReviewStatus()).isEqualTo("PENDING");
|
||||
assertThat(measurement.getStatus()).isEqualTo("SUBMITTED");
|
||||
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("20.000");
|
||||
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("20.000");
|
||||
assertThat(inbound.getPalletCount()).isEqualTo(1);
|
||||
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("1.2345");
|
||||
}
|
||||
@@ -711,7 +757,7 @@ class InboundMeasurementServiceTest {
|
||||
when(batchService.saveBatch(any())).thenReturn(saved);
|
||||
|
||||
InboundMeasurementResponses.DraftSaved result = service.saveDraft(1L, 10L, 9L,
|
||||
new InboundMeasurementRequests.SaveDraftRequest(null, 0, null, null, null, null,
|
||||
new InboundMeasurementRequests.SaveDraftRequest(null, 0, null,
|
||||
List.of(new InboundMeasurementRequests.ItemRequest(80001L, "001", "A", 2,
|
||||
new BigDecimal("40"), new BigDecimal("30"), new BigDecimal("20"),
|
||||
new BigDecimal("3.250"), null, null, null, null, null, null))));
|
||||
@@ -720,13 +766,38 @@ class InboundMeasurementServiceTest {
|
||||
ArgumentCaptor<MeasurementBatchCommand> command = ArgumentCaptor.forClass(MeasurementBatchCommand.class);
|
||||
verify(batchService).saveBatch(command.capture());
|
||||
assertThat(command.getValue().items().get(0).inboundDetailId()).isEqualTo(80001L);
|
||||
assertThat(command.getValue().originalDocumentNo()).isEqualTo("REF-50");
|
||||
assertThat(command.getValue().originalGrossWeightKg()).isEqualByComparingTo("2000");
|
||||
assertThat(command.getValue().originalVolumeCbm()).isEqualByComparingTo("20");
|
||||
assertThat(command.getValue().actualGoodsName()).isEqualTo("123123");
|
||||
verify(orderCargoClient).detail("internal-token", 1L, 80L, 80001L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void savingDraftRejectsOverlongDefaultGoodsNameBeforeDatabaseWrite() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
|
||||
InboundMeasurementRequests.ItemRequest item = new InboundMeasurementRequests.ItemRequest(
|
||||
80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
|
||||
new BigDecimal("20"), new BigDecimal("3.250"), null, null, null, null, null, null);
|
||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
||||
when(orderCargoClient.detail("internal-token", 1L, 80L, 80001L)).thenReturn(
|
||||
ApiResponse.success(new OrderCargoClient.CargoBinding(
|
||||
80001L, 80L, 1, "Test", 2, new BigDecimal("6.5"), new BigDecimal("0.1"))));
|
||||
when(orderCargoClient.measurementDeclaration("internal-token", 1L, 80L)).thenReturn(
|
||||
ApiResponse.success(new OrderCargoClient.MeasurementDeclarationSnapshot(
|
||||
"REF", new BigDecimal("6.5"), new BigDecimal("0.1"), "A".repeat(256))));
|
||||
|
||||
assertThatThrownBy(() -> service.saveDraft(1L, 10L, 9L,
|
||||
new InboundMeasurementRequests.SaveDraftRequest(null, 0, null, List.of(item))))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("超过255字符");
|
||||
verify(batchService, never()).saveBatch(any());
|
||||
}
|
||||
|
||||
private InboundOrderEntity inbound(String status) {
|
||||
InboundOrderEntity inbound = new InboundOrderEntity();
|
||||
inbound.setId(10L);
|
||||
inbound.setTenantId(1L);
|
||||
inbound.setOrderId(80L);
|
||||
inbound.setWarehouseId(100L);
|
||||
inbound.setProcessStatus(status);
|
||||
inbound.setTotalPackageCount(3);
|
||||
|
||||
+19
-1
@@ -9,6 +9,8 @@ import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
|
||||
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
|
||||
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
@@ -41,6 +43,7 @@ import static org.mockito.Mockito.when;
|
||||
@SpringJUnitConfig(MeasurementReviewService.class)
|
||||
class MeasurementReviewServiceTest {
|
||||
@MockBean private InboundWorkflowRepository repository;
|
||||
@MockBean private InboundMeasurementRepository measurements;
|
||||
@MockBean private InboundIdempotencyService idempotency;
|
||||
@MockBean private InboundProperties properties;
|
||||
@Resource private MeasurementReviewService service;
|
||||
@@ -212,7 +215,8 @@ class MeasurementReviewServiceTest {
|
||||
order.setProcessStatus("MEASURE_PENDING");
|
||||
order.setMeasureReviewStatus("PENDING");
|
||||
order.setTotalPackageCount(5);
|
||||
order.setActualGrossWeightKg(new BigDecimal("11.500"));
|
||||
order.setActualGrossWeightKg(new BigDecimal("1787.500"));
|
||||
order.setActualNetWeightKg(new BigDecimal("1747.500"));
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(20L);
|
||||
measurement.setStatus("SUBMITTED");
|
||||
@@ -224,9 +228,23 @@ class MeasurementReviewServiceTest {
|
||||
when(repository.findOwned(10001L, 10L)).thenReturn(order);
|
||||
when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement);
|
||||
when(repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item));
|
||||
when(measurements.listWeights(10001L, 10L)).thenReturn(List.of(
|
||||
confirmedWeight("357.500", "349.500"),
|
||||
confirmedWeight("357.500", "349.500"),
|
||||
confirmedWeight("357.500", "349.500"),
|
||||
confirmedWeight("357.500", "349.500"),
|
||||
confirmedWeight("357.500", "349.500")));
|
||||
return new Fixture(service, repository, idempotency, order, measurement);
|
||||
}
|
||||
|
||||
private InboundWeightResultEntity confirmedWeight(String grossWeightKg, String netWeightKg) {
|
||||
InboundWeightResultEntity weight = new InboundWeightResultEntity();
|
||||
weight.setRecognitionStatus("CONFIRMED");
|
||||
weight.setGrossWeightKg(new BigDecimal(grossWeightKg));
|
||||
weight.setNetWeightKg(new BigDecimal(netWeightKg));
|
||||
return weight;
|
||||
}
|
||||
|
||||
private record Fixture(MeasurementReviewService service, InboundWorkflowRepository repository,
|
||||
InboundIdempotencyService idempotency, InboundOrderEntity order,
|
||||
InboundMeasurementEntity measurement) {
|
||||
|
||||
@@ -55,7 +55,8 @@ public interface FirstlegUnifiedOrderClient {
|
||||
BigDecimal chargeableWeightKg, LocalDateTime measureCompletedAt,
|
||||
LocalDateTime inboundedAt, String expressCompanyName, String createdByName,
|
||||
String containerNo, String cabinetNo, String vesselName, String voyageNo,
|
||||
LocalDateTime sailingAt, LocalDateTime portArrivalAt,
|
||||
LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt,
|
||||
LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt,
|
||||
String expressTrackingNo, LocalDateTime createdAt, LocalDateTime updatedAt) {}
|
||||
record Preload(String status, Integer version, Integer itemCount, LocalDateTime updatedAt) {}
|
||||
record Loading(String status, Integer loadingVersion, Integer pickingVersion,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.order.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.cloud.context.config.annotation.RefreshScope;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/** 从共享配置读取全系统唯一的入仓码运行模式,并拒绝任何未知配置。 */
|
||||
@Component
|
||||
@RefreshScope
|
||||
public class InboundCodeModeProvider {
|
||||
public enum Mode { AUTO, LEGACY_MANUAL }
|
||||
|
||||
private final Mode mode;
|
||||
|
||||
public InboundCodeModeProvider(@Value("${baiyue.order.inbound-code-mode}") String configuredMode) {
|
||||
if (!StringUtils.hasText(configuredMode)) {
|
||||
throw new IllegalStateException("baiyue.order.inbound-code-mode 必须显式配置");
|
||||
}
|
||||
try {
|
||||
mode = Mode.valueOf(configuredMode.trim().toUpperCase(Locale.ROOT));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IllegalStateException(
|
||||
"baiyue.order.inbound-code-mode 仅支持 AUTO 或 LEGACY_MANUAL", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public Mode mode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
public boolean legacyManual() {
|
||||
return mode == Mode.LEGACY_MANUAL;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.List;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 供头程履约服务校验入仓单所关联订单货物明细。 */
|
||||
@RestController
|
||||
@@ -89,6 +92,32 @@ public class InternalOrderCargoController {
|
||||
return ApiResponse.success(OrderApplicationService.inboundCommand(order, packageCount));
|
||||
}
|
||||
|
||||
@GetMapping("/{tenantId}/{orderId}/measurement-declaration")
|
||||
public ApiResponse<MeasurementDeclarationSnapshot> measurementDeclaration(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@PathVariable long tenantId, @PathVariable long orderId) {
|
||||
requireToken(token);
|
||||
if (tenantId <= 0 || orderId <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "租户和订单ID必须大于0");
|
||||
}
|
||||
var order = orders.findOwned(tenantId, orderId);
|
||||
if (order == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在或不属于当前客户");
|
||||
// 申报重量/体积必须属于当前订单。拆分子单未物化独立货物事实时不得复制根单整单总量。
|
||||
List<OrderCargoEntity> cargoes = orders.findCargo(tenantId, orderId);
|
||||
return ApiResponse.success(new MeasurementDeclarationSnapshot(
|
||||
order.getReferenceId(), sum(cargoes, OrderCargoEntity::getGrossWeightKg),
|
||||
sum(cargoes, OrderCargoEntity::getVolumeCbm),
|
||||
cargoes.stream().map(OrderCargoEntity::getCargoName).filter(Objects::nonNull)
|
||||
.map(String::trim).filter(value -> !value.isEmpty()).distinct()
|
||||
.collect(Collectors.joining("、"))));
|
||||
}
|
||||
|
||||
private BigDecimal sum(List<OrderCargoEntity> cargoes,
|
||||
java.util.function.Function<OrderCargoEntity, BigDecimal> getter) {
|
||||
List<BigDecimal> values = cargoes.stream().map(getter).filter(Objects::nonNull).toList();
|
||||
return values.isEmpty() ? null : values.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
|
||||
private void requireToken(String token) {
|
||||
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
|
||||
@@ -113,4 +142,8 @@ public class InternalOrderCargoController {
|
||||
Integer packageCount, java.math.BigDecimal lengthCm,
|
||||
java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) {
|
||||
}
|
||||
|
||||
public record MeasurementDeclarationSnapshot(
|
||||
String referenceId, BigDecimal originalGrossWeightKg,
|
||||
BigDecimal originalVolumeCbm, String defaultActualGoodsName) {}
|
||||
}
|
||||
|
||||
+8
-1
@@ -9,6 +9,7 @@ import com.baiyue.order.dto.OrderRequests;
|
||||
import com.baiyue.order.dto.MerchantBatchDraftDtos;
|
||||
import com.baiyue.order.dto.MerchantInboundOrderQuery;
|
||||
import com.baiyue.order.dto.MerchantInboundOrderRow;
|
||||
import com.baiyue.order.dto.InboundCodePolicyResponse;
|
||||
import com.baiyue.order.entity.OrderStatus;
|
||||
import com.baiyue.order.service.OrderApplicationService;
|
||||
import com.baiyue.order.service.OrderScopeGuard;
|
||||
@@ -50,6 +51,12 @@ public class MerchantFirstLegOrderController {
|
||||
@Resource
|
||||
private MerchantBatchDraftService batchDraftService;
|
||||
|
||||
@GetMapping("/inbound-code-policy")
|
||||
@PreAuthorize("hasAnyAuthority('merchant:order:list', 'merchant:order:create', 'merchant:order:edit')")
|
||||
public ApiResponse<InboundCodePolicyResponse> inboundCodePolicy() {
|
||||
return ApiResponse.success(service.inboundCodePolicy());
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_CREATE", resourceType = "FIRST_LEG_ORDER", description = "Create draft / 新建草稿")
|
||||
@Operation(summary = "Create draft / 新建草稿", description = "Creates a first-leg draft order in the authenticated merchant tenant. / 在当前认证商户租户下创建头程订单草稿。")
|
||||
@ApiResponses({
|
||||
@@ -69,7 +76,7 @@ public class MerchantFirstLegOrderController {
|
||||
|
||||
@BusinessAudit(event = "ORDER_BATCH_CREATE", resourceType = "FIRST_LEG_ORDER", description = "Batch create drafts / 批量创建草稿")
|
||||
@Operation(summary = "Batch create drafts / 批量创建草稿",
|
||||
description = "每个数组元素代表一张草稿;单行失败不影响其它行,响应直接返回订单号和入仓码。")
|
||||
description = "每个数组元素代表一张草稿;单行失败不影响其它行。AUTO 模式的入仓码在提交订单时生成,草稿结果允许为空。")
|
||||
@PostMapping("/batch")
|
||||
@PreAuthorize("hasAuthority('merchant:order:create')")
|
||||
public ApiResponse<MerchantBatchDraftDtos.Response> batchCreate(
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.baiyue.order.dto;
|
||||
|
||||
/** 全系统统一的入仓码录入策略。 */
|
||||
public record InboundCodePolicyResponse(String mode, boolean manualEntryRequired) {
|
||||
}
|
||||
@@ -71,8 +71,27 @@ public final class OrderRequests {
|
||||
@NotBlank @Pattern(regexp = "FBA|OVERSEAS|PRIVATE") String destinationType,
|
||||
@Size(max = 32) String amazonWarehouseCode,
|
||||
@Size(max = 64) String shipmentId,
|
||||
@Size(max = 64) String referenceId
|
||||
@Size(max = 64) String referenceId,
|
||||
@Schema(description = "旧系统入仓码;仅全局 LEGACY_MANUAL 模式接受,AUTO 模式必须留空", example = "NYEXX2608140001", maxLength = 40)
|
||||
@Size(max = 40) @Pattern(regexp = "[A-Za-z0-9._:-]+") String inboundCode
|
||||
) {
|
||||
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
|
||||
String destinationCountryCode, Long destinationWarehouseId,
|
||||
String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark,
|
||||
Party sender, Party receiver, List<Cargo> cargoes, String pickupMethod,
|
||||
LocalDateTime requestedPickupAt, Long receivingWarehouseId,
|
||||
String receivingWarehouseName, String channelCode, String channelName,
|
||||
Long carrierId, String carrierName, String taxMode, String pricingTaxMode,
|
||||
String containerMode, Boolean customsInspection, String destinationType,
|
||||
String amazonWarehouseCode, String shipmentId, String referenceId) {
|
||||
this(customerId, customerName, transportMode, tradeTerm, destinationCountryCode,
|
||||
destinationWarehouseId, destinationWarehouseName, expectedShipDate, customerRemark,
|
||||
sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
|
||||
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
|
||||
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
|
||||
shipmentId, referenceId, null);
|
||||
}
|
||||
|
||||
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
|
||||
String destinationCountryCode, Long destinationWarehouseId,
|
||||
String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark,
|
||||
@@ -87,7 +106,7 @@ public final class OrderRequests {
|
||||
sender, receiver, cargoes, pickupMethod, null, receivingWarehouseId,
|
||||
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
|
||||
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
|
||||
shipmentId, referenceId);
|
||||
shipmentId, referenceId, null);
|
||||
}
|
||||
|
||||
@AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空")
|
||||
|
||||
@@ -25,7 +25,7 @@ public final class UnifiedOrderResponse {
|
||||
String companyName, String businessType, String pickupMethod,
|
||||
String taxMode, String containerMode, Boolean customsInspection,
|
||||
String destinationType, String destinationCode,
|
||||
Integer totalPalletCount, BigDecimal chargeableVolumeCbm,
|
||||
Integer totalPalletCount, BigDecimal chargeableWeightKg, BigDecimal chargeableVolumeCbm,
|
||||
BigDecimal volumetricWeightKg, BigDecimal totalNetWeightKg,
|
||||
LocalDateTime measurementCompletedAt, LocalDateTime inboundCompletedAt,
|
||||
String expressCompany, String expressTrackingNo, String createdBy,
|
||||
@@ -52,8 +52,11 @@ public final class UnifiedOrderResponse {
|
||||
String sealNo, LocalDateTime updatedAt) {}
|
||||
public record DeliverySummary(String status, LocalDateTime completedAt, String trackingNo) {}
|
||||
public record TransportSummary(String containerNo, String cabinetNo,
|
||||
String vesselName, String voyageNo, LocalDateTime sailingAt,
|
||||
LocalDateTime portArrivalAt) {}
|
||||
String vesselName, String voyageNo,
|
||||
LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt,
|
||||
LocalDateTime effectiveSailingAt, Boolean effectiveSailingAtEstimated,
|
||||
LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt,
|
||||
LocalDateTime effectivePortArrivalAt, Boolean effectivePortArrivalAtEstimated) {}
|
||||
public record TimelineEvent(String source, String eventType, String mainStatus, String action,
|
||||
String remark, Long operatorId, String operatorName,
|
||||
LocalDateTime occurredAt) {}
|
||||
|
||||
@@ -121,6 +121,14 @@ public class OrderRepository {
|
||||
.eq(OrderEntity::getId, orderId));
|
||||
}
|
||||
|
||||
public boolean inboundCodeExists(long tenantId, String inboundCode, Long excludedOrderId) {
|
||||
if (!StringUtils.hasText(inboundCode)) return false;
|
||||
return orderMapper.selectCount(new LambdaQueryWrapper<OrderEntity>()
|
||||
.eq(OrderEntity::getTenantId, tenantId)
|
||||
.eq(OrderEntity::getInboundCode, inboundCode)
|
||||
.ne(excludedOrderId != null, OrderEntity::getId, excludedOrderId)) > 0;
|
||||
}
|
||||
|
||||
public PageResult<OrderEntity> page(Long tenantId, OrderRequests.QueryRequest query) {
|
||||
LambdaQueryWrapper<OrderEntity> wrapper = new LambdaQueryWrapper<OrderEntity>()
|
||||
.eq(tenantId != null, OrderEntity::getTenantId, tenantId)
|
||||
|
||||
@@ -6,9 +6,11 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.dto.OrderDetailResponse;
|
||||
import com.baiyue.order.dto.OrderRequests;
|
||||
import com.baiyue.order.dto.InboundCodePolicyResponse;
|
||||
import com.baiyue.order.dto.TenantOrderCountResponse;
|
||||
import com.baiyue.order.dto.TenantOrderStatisticsResponse;
|
||||
import com.baiyue.order.client.FirstlegInboundClient;
|
||||
import com.baiyue.order.config.InboundCodeModeProvider;
|
||||
import com.baiyue.order.entity.*;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
@@ -45,9 +47,15 @@ public class OrderApplicationService {
|
||||
@Resource private OrderMasterDataGuard masterDataGuard;
|
||||
@Resource private OrderPricingChannelGuard pricingChannelGuard;
|
||||
@Resource private ProductImageGuard productImageGuard;
|
||||
@Resource private InboundCodeModeProvider inboundCodeModeProvider;
|
||||
@Resource private ObjectProvider<FirstlegInboundClient> firstlegProvider;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
|
||||
public InboundCodePolicyResponse inboundCodePolicy() {
|
||||
boolean manual = legacyManualMode();
|
||||
return new InboundCodePolicyResponse(inboundCodeModeProvider.mode().name(), manual);
|
||||
}
|
||||
|
||||
/** 创建草稿时使用数据库唯一键保证请求幂等。 */
|
||||
@Transactional
|
||||
public Long createDraft(long tenantId, long userId, OrderRequests.SaveRequest request, String idempotencyKey) {
|
||||
@@ -64,8 +72,8 @@ public class OrderApplicationService {
|
||||
private Long createDraftInternal(long tenantId, long userId, OrderRequests.SaveRequest request) {
|
||||
productImageGuard.requireOwned(tenantId, request.cargoes());
|
||||
var snapshots = masterDataGuard.validate(tenantId, request);
|
||||
pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(),
|
||||
request.channelCode(), request.destinationType());
|
||||
requirePricingChannel(tenantId, request.receivingWarehouseId(), request.channelCode(),
|
||||
request.destinationType());
|
||||
OrderEntity order = new OrderEntity();
|
||||
long orderId = IdWorker.getId();
|
||||
order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId));
|
||||
@@ -104,7 +112,7 @@ public class OrderApplicationService {
|
||||
&& !(allowSubmitted && before == OrderStatus.SUBMITTED)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "当前订单状态不允许编辑订单资料");
|
||||
}
|
||||
pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode(),
|
||||
requirePricingChannel(tenantId, request.receivingWarehouseId(), request.channelCode(),
|
||||
request.destinationType());
|
||||
apply(order, tenantId, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
|
||||
if (before == OrderStatus.SUBMITTED) {
|
||||
@@ -129,7 +137,7 @@ public class OrderApplicationService {
|
||||
validatePersistedPrototypeFields(tenantId, order);
|
||||
masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(),
|
||||
order.getDestinationWarehouseId(), order.getDestinationType());
|
||||
inboundCodePrefix = pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(),
|
||||
inboundCodePrefix = requirePricingChannel(tenantId, order.getReceivingWarehouseId(),
|
||||
order.getChannelCode(), order.getDestinationType());
|
||||
}
|
||||
if ((target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED) && !StringUtils.hasText(reason)) {
|
||||
@@ -137,7 +145,13 @@ public class OrderApplicationService {
|
||||
}
|
||||
order.setStatus(target); order.setUpdatedBy(userId);
|
||||
if (target == OrderStatus.SUBMITTED) {
|
||||
if (!StringUtils.hasText(order.getInboundCode())) order.setInboundCode(nextInboundCode(inboundCodePrefix));
|
||||
if (!StringUtils.hasText(order.getInboundCode())) {
|
||||
if (legacyManualMode()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "当前系统使用旧系统入仓码,请先填写入仓码");
|
||||
}
|
||||
order.setInboundCode(nextInboundCode(inboundCodePrefix));
|
||||
}
|
||||
requirePrintableInboundCode(order);
|
||||
order.setSubmittedAt(LocalDateTime.now());
|
||||
}
|
||||
if (target == OrderStatus.COMPLETED) order.setCompletedAt(LocalDateTime.now());
|
||||
@@ -414,6 +428,7 @@ public class OrderApplicationService {
|
||||
}
|
||||
private void apply(OrderEntity order, long tenantId, OrderRequests.SaveRequest request, OrderMasterDataGuard.Snapshots snapshots) {
|
||||
order.setCustomerId(tenantId);
|
||||
applyInboundCode(order, request.inboundCode());
|
||||
order.setCustomerName(request.customerName().trim());
|
||||
order.setTransportMode(request.transportMode()); order.setTradeTerm(request.tradeTerm());
|
||||
order.setDestinationCountryCode(snapshots.destination() == null ? request.destinationCountryCode()
|
||||
@@ -443,6 +458,48 @@ public class OrderApplicationService {
|
||||
? snapshots.destination().warehouse().warehouseCode() : request.amazonWarehouseCode());
|
||||
order.setShipmentId(request.shipmentId()); order.setReferenceId(request.referenceId());
|
||||
}
|
||||
private void applyInboundCode(OrderEntity order, String requestedCode) {
|
||||
String normalized = StringUtils.hasText(requestedCode)
|
||||
? requestedCode.trim().toUpperCase(Locale.ROOT) : null;
|
||||
if (!legacyManualMode()) {
|
||||
if (normalized != null && !normalized.equals(order.getInboundCode())) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "当前系统由新系统自动生成入仓码,请勿人工填写");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (order.getStatus() != OrderStatus.DRAFT && StringUtils.hasText(order.getInboundCode())) {
|
||||
if (normalized != null && !normalized.equals(order.getInboundCode())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "入仓码提交后不可修改");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (normalized != null && repository.inboundCodeExists(order.getTenantId(), normalized, order.getId())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "入仓码已存在,请核对旧系统数据");
|
||||
}
|
||||
order.setInboundCode(normalized);
|
||||
}
|
||||
private boolean legacyManualMode() {
|
||||
return inboundCodeModeProvider.legacyManual();
|
||||
}
|
||||
private String requirePricingChannel(long tenantId, long receivingWarehouseId, String channelCode,
|
||||
String destinationType) {
|
||||
if (legacyManualMode()) {
|
||||
pricingChannelGuard.requireActive(tenantId, receivingWarehouseId, channelCode, destinationType);
|
||||
return null;
|
||||
}
|
||||
return pricingChannelGuard.requireAvailable(tenantId, receivingWarehouseId, channelCode, destinationType);
|
||||
}
|
||||
private void requirePrintableInboundCode(OrderEntity order) {
|
||||
String inboundCode = order.getInboundCode();
|
||||
if (!StringUtils.hasText(inboundCode) || !inboundCode.matches("[A-Za-z0-9._:-]+")) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码格式不正确");
|
||||
}
|
||||
int total = totalPackageCount(order);
|
||||
String longestCartonCode = inboundCode + "#" + total + "-" + total;
|
||||
if (longestCartonCode.codePointCount(0, longestCartonCode.length()) > 34) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码过长,无法生成箱唛二维码");
|
||||
}
|
||||
}
|
||||
private String formatAddress(com.baiyue.order.client.WarehouseClient.Detail detail) {
|
||||
if (detail.addresses() == null) return null;
|
||||
var a = detail.addresses().stream().filter(java.util.Objects::nonNull).findFirst().orElse(null);
|
||||
|
||||
@@ -20,6 +20,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Comparator;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
@@ -180,10 +181,10 @@ public class UnifiedOrderQueryService {
|
||||
order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
|
||||
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null,
|
||||
order.getInboundCode(),
|
||||
order.getExpectedShipDate(), null, null, null, null,
|
||||
order.getExpectedShipDate(), totalPackageCount(cargo), null, null, null,
|
||||
null, null, null, null, null, order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(),
|
||||
order.getCustomsInspection(), order.getDestinationType(), order.getAmazonWarehouseCode(),
|
||||
null, null, null, null, null, null, order.getExpressCompany(), order.getExpressTrackingNo(),
|
||||
null, null, null, null, null, null, null, order.getExpressCompany(), order.getExpressTrackingNo(),
|
||||
string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(),
|
||||
null, null, null, null, null,
|
||||
order.getCustomerRemark(), latestInternalNote,
|
||||
@@ -199,7 +200,7 @@ public class UnifiedOrderQueryService {
|
||||
inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), inbound.expectedArrivalAt(), inbound.feeReviewStatus(),
|
||||
inbound.companyName(), inbound.businessType(), order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(),
|
||||
inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(),
|
||||
inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(),
|
||||
inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(),
|
||||
inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(),
|
||||
inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(), measurement(inbound),
|
||||
preload(summary.preload()), loading(summary.loading()),
|
||||
@@ -221,7 +222,7 @@ public class UnifiedOrderQueryService {
|
||||
inbound.actualVolumeCbm(), inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(),
|
||||
inbound.businessType(), order == null ? null : order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(),
|
||||
inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(),
|
||||
inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(),
|
||||
inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(),
|
||||
inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(),
|
||||
inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order == null ? null : order.getShipmentId(),
|
||||
order == null ? null : order.getReferenceId(), measurement(inbound), preload(summary.preload()), loading(summary.loading()),
|
||||
@@ -248,8 +249,15 @@ public class UnifiedOrderQueryService {
|
||||
value.status(), value.completedAt(), value.trackingNo());
|
||||
}
|
||||
private UnifiedOrderResponse.TransportSummary transport(FirstlegUnifiedOrderClient.Inbound inbound) {
|
||||
LocalDateTime sailingAt = inbound.actualSailingAt() != null
|
||||
? inbound.actualSailingAt() : inbound.estimatedSailingAt();
|
||||
LocalDateTime arrivalAt = inbound.actualPortArrivalAt() != null
|
||||
? inbound.actualPortArrivalAt() : inbound.estimatedPortArrivalAt();
|
||||
return new UnifiedOrderResponse.TransportSummary(inbound.containerNo(), inbound.cabinetNo(),
|
||||
inbound.vesselName(), inbound.voyageNo(), inbound.sailingAt(), inbound.portArrivalAt());
|
||||
inbound.vesselName(), inbound.voyageNo(), inbound.estimatedSailingAt(), inbound.actualSailingAt(),
|
||||
sailingAt, sailingAt == null ? null : inbound.actualSailingAt() == null,
|
||||
inbound.estimatedPortArrivalAt(), inbound.actualPortArrivalAt(), arrivalAt,
|
||||
arrivalAt == null ? null : inbound.actualPortArrivalAt() == null);
|
||||
}
|
||||
|
||||
private String orderMainStatus(OrderStatus status) {
|
||||
@@ -270,13 +278,21 @@ public class UnifiedOrderQueryService {
|
||||
return cargo == null ? null : cargo.totalQuantity();
|
||||
}
|
||||
|
||||
private record CargoSummary(List<String> skuNos, Integer totalQuantity) {
|
||||
private Integer totalPackageCount(CargoSummary cargo) {
|
||||
return cargo == null ? null : cargo.totalPackageCount();
|
||||
}
|
||||
|
||||
private record CargoSummary(List<String> skuNos, Integer totalQuantity, Integer totalPackageCount) {
|
||||
private static CargoSummary from(List<OrderCargoEntity> cargoes) {
|
||||
List<String> skuNos = cargoes.stream().map(OrderCargoEntity::getSkuNo)
|
||||
.filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).distinct().toList();
|
||||
int totalQuantity = cargoes.stream().map(OrderCargoEntity::getTotalQuantity)
|
||||
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
|
||||
return new CargoSummary(skuNos, totalQuantity);
|
||||
List<Integer> packageCounts = cargoes.stream().map(OrderCargoEntity::getPackageCount)
|
||||
.filter(Objects::nonNull).toList();
|
||||
Integer totalPackageCount = packageCounts.isEmpty() ? null
|
||||
: Math.toIntExact(packageCounts.stream().mapToLong(Integer::longValue).sum());
|
||||
return new CargoSummary(skuNos, totalQuantity, totalPackageCount);
|
||||
}
|
||||
}
|
||||
private String inboundMainStatus(String status) {
|
||||
|
||||
@@ -13,3 +13,5 @@ spring:
|
||||
url: ${ORDER_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true}
|
||||
username: ${ORDER_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}}
|
||||
password: ${ORDER_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}}
|
||||
|
||||
baiyue.order.inbound-code-mode: AUTO
|
||||
|
||||
@@ -35,7 +35,6 @@ baiyue:
|
||||
url: ${WAREHOUSE_SERVICE_URL:}
|
||||
file:
|
||||
url: ${FILE_SERVICE_URL:}
|
||||
|
||||
logging:
|
||||
file:
|
||||
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
|
||||
|
||||
@@ -11,7 +11,8 @@ paths:
|
||||
summary: 平台统一订单列表
|
||||
description: >-
|
||||
统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。
|
||||
订单创建时按物流渠道生成固定 inboundCode,格式为物流前缀+YYMMDD+当天全平台票序;每日按上海日期从0001重新开始;有入库单时同时返回
|
||||
入仓码来源由全系统统一模式控制:AUTO 在订单提交时按物流渠道生成,格式为物流前缀+YYMMDD+当天全平台票序;
|
||||
LEGACY_MANUAL 在草稿中录入旧系统入仓码。两种模式下入仓码一经提交均固定;有入库单时同时返回
|
||||
inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion
|
||||
才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。
|
||||
tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询,
|
||||
@@ -197,6 +198,23 @@ paths:
|
||||
schema: {$ref: '#/components/schemas/FirstLegBatchDraftResponse'}
|
||||
'400': {description: 请求数组为空、超过 50 项或幂等键缺失}
|
||||
'403': {description: 需要 merchant:order:create 权限}
|
||||
/api/merchant/v1/orders/first-leg/inbound-code-policy:
|
||||
get:
|
||||
summary: 查询全系统入仓码模式
|
||||
description: >-
|
||||
返回订单服务当前统一运行模式。AUTO 表示提交时由新系统生成;LEGACY_MANUAL
|
||||
表示两套系统并跑期间必须录入旧系统生成的入仓码。该策略不按客户区分。
|
||||
responses:
|
||||
'200':
|
||||
description: 查询成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string, example: '200'}
|
||||
data: {$ref: '#/components/schemas/InboundCodePolicy'}
|
||||
'403': {description: 需要 merchant:order:list、merchant:order:create 或 merchant:order:edit 权限之一}
|
||||
/api/merchant/v1/orders/first-leg/{id}/submit:
|
||||
post:
|
||||
summary: 提交订单
|
||||
@@ -423,10 +441,10 @@ components:
|
||||
inboundVersion: {type: integer, nullable: true, description: C04 loading/complete 的 expectedVersion}
|
||||
inboundCode: {type: string, nullable: true}
|
||||
expectedShipDate: {type: string, format: date, nullable: true}
|
||||
totalPackageCount: {type: integer, nullable: true}
|
||||
totalPackageCount: {type: integer, nullable: true, description: 无入仓事实时为当前订单有效货物箱数合计;存在入仓业务单元后为该单元履约总箱数,拆分后不以父单原始箱数覆盖}
|
||||
recommendedPalletType: {type: string, nullable: true, description: 当前无列表级权威事实时返回 null,不得返回固定托盘类型}
|
||||
locationCodes: {type: string, nullable: true}
|
||||
actualGrossWeightKg: {type: number, nullable: true}
|
||||
actualGrossWeightKg: {type: number, nullable: true, description: Firstleg 实测毛重;来源 actualGrossWeightKg,不以净重回退}
|
||||
actualVolumeCbm: {type: number, nullable: true}
|
||||
expectedArrivalAt: {type: string, format: date-time, nullable: true}
|
||||
feeReviewStatus: {type: string, nullable: true, description: Firstleg 入仓费用审核快照;财务状态不在本字段表达}
|
||||
@@ -440,9 +458,10 @@ components:
|
||||
destinationType: {type: string, nullable: true}
|
||||
destinationCode: {type: string, nullable: true}
|
||||
totalPalletCount: {type: integer, nullable: true}
|
||||
chargeableWeightKg: {type: number, nullable: true, description: Firstleg confirmed billing snapshot chargeable weight in kilograms}
|
||||
chargeableVolumeCbm: {type: number, nullable: true}
|
||||
volumetricWeightKg: {type: number, nullable: true}
|
||||
totalNetWeightKg: {type: number, nullable: true, description: Firstleg 确认后的实际净重;未确认时返回 null}
|
||||
totalNetWeightKg: {type: number, nullable: true, description: 统一订单货物净重展示字段;严格来源 Firstleg actualNetWeightKg,不以毛重回退}
|
||||
measurementCompletedAt: {type: string, format: date-time, nullable: true}
|
||||
inboundCompletedAt: {type: string, format: date-time, nullable: true}
|
||||
expressCompany: {type: string, nullable: true}
|
||||
@@ -475,7 +494,7 @@ components:
|
||||
nullable: true
|
||||
properties:
|
||||
reviewStatus: {type: string, nullable: true}
|
||||
actualGrossWeightKg: {type: number, nullable: true}
|
||||
actualGrossWeightKg: {type: number, nullable: true, description: Firstleg 实测毛重;不得使用净重回退}
|
||||
actualVolumeCbm: {type: number, nullable: true}
|
||||
chargeableWeightKg: {type: number, nullable: true}
|
||||
completedAt: {type: string, format: date-time, nullable: true}
|
||||
@@ -514,8 +533,14 @@ components:
|
||||
cabinetNo: {type: string, nullable: true}
|
||||
vesselName: {type: string, nullable: true}
|
||||
voyageNo: {type: string, nullable: true}
|
||||
sailingAt: {type: string, format: date-time, nullable: true}
|
||||
portArrivalAt: {type: string, format: date-time, nullable: true}
|
||||
estimatedSailingAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次预计开船时间}
|
||||
actualSailingAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次实际开船时间}
|
||||
effectiveSailingAt: {type: string, format: date-time, nullable: true, description: 实际开船时间优先,否则预计开船时间}
|
||||
effectiveSailingAtEstimated: {type: boolean, nullable: true}
|
||||
estimatedPortArrivalAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次预计到港时间}
|
||||
actualPortArrivalAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次实际到港时间}
|
||||
effectivePortArrivalAt: {type: string, format: date-time, nullable: true, description: 实际到港时间优先,否则预计到港时间}
|
||||
effectivePortArrivalAtEstimated: {type: boolean, nullable: true}
|
||||
UnifiedOrderTimelineEvent:
|
||||
type: object
|
||||
properties:
|
||||
@@ -606,6 +631,11 @@ components:
|
||||
type: object
|
||||
required: [pricingTaxMode, cargoes]
|
||||
properties:
|
||||
inboundCode:
|
||||
type: string
|
||||
maxLength: 40
|
||||
pattern: '^[A-Za-z0-9._:-]+$'
|
||||
description: 仅 LEGACY_MANUAL 模式填写的旧系统入仓码;AUTO 模式必须省略
|
||||
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
|
||||
cargoes:
|
||||
type: array
|
||||
@@ -615,6 +645,12 @@ components:
|
||||
订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;pricingTaxMode
|
||||
是新建和编辑订单的必填报价维度;customerId 不接受客户端指定,服务端始终使用认证企业 ID;
|
||||
发货单位和收货单位仅通过 sender/receiver 保存业务快照。历史存量订单的响应字段仍允许为空。
|
||||
InboundCodePolicy:
|
||||
type: object
|
||||
required: [mode, manualEntryRequired]
|
||||
properties:
|
||||
mode: {type: string, enum: [AUTO, LEGACY_MANUAL]}
|
||||
manualEntryRequired: {type: boolean}
|
||||
FirstLegBatchDraftItem:
|
||||
type: object
|
||||
required: [clientRowId, draft]
|
||||
@@ -644,7 +680,7 @@ components:
|
||||
success: {type: boolean}
|
||||
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
orderNo: {type: string}
|
||||
inboundCode: {type: string, pattern: '^[A-Z]{4,6}[0-9]{10,}(?:-[1-9][0-9]*)?$', example: ZBYHK2608140001, description: 根订单使用渠道前缀+业务日期+全局票序;履约拆分产生的余量子订单追加根范围 splitSequence 后缀,如 ZBYHK2608140001-1}
|
||||
inboundCode: {type: string, pattern: '^[A-Za-z0-9._:-]+$', example: ZBYHK2608140001, description: AUTO 模式为渠道前缀+业务日期+全局票序;LEGACY_MANUAL 模式为录入的旧系统入仓码;履约拆分的子订单追加 splitSequence 后缀}
|
||||
errorCode: {type: string}
|
||||
errorMessage: {type: string}
|
||||
FirstLegOrderCargo:
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.order.controller;
|
||||
|
||||
import com.baiyue.order.dto.InboundCodePolicyResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class InboundCodePolicyContractTest {
|
||||
@Test
|
||||
void exposesOneGlobalPolicyToOrderReadersAndEditors() throws Exception {
|
||||
var method = MerchantFirstLegOrderController.class.getDeclaredMethod("inboundCodePolicy");
|
||||
var authorization = method.getAnnotation(PreAuthorize.class);
|
||||
|
||||
assertThat(authorization).isNotNull();
|
||||
assertThat(authorization.value())
|
||||
.isEqualTo("hasAnyAuthority('merchant:order:list', 'merchant:order:create', 'merchant:order:edit')");
|
||||
assertThat(method.getGenericReturnType().getTypeName())
|
||||
.contains(InboundCodePolicyResponse.class.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void documentsAutomaticAndLegacyManualModes() throws Exception {
|
||||
String openapi = new ClassPathResource("openapi/order-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(openapi)
|
||||
.contains("/api/merchant/v1/orders/first-leg/inbound-code-policy:")
|
||||
.contains("enum: [AUTO, LEGACY_MANUAL]")
|
||||
.contains("manualEntryRequired: {type: boolean}")
|
||||
.contains("该策略不按客户区分")
|
||||
.contains("仅 LEGACY_MANUAL 模式填写的旧系统入仓码;AUTO 模式必须省略");
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,42 @@ class InternalOrderCargoControllerTest {
|
||||
verify(orders).findOwned(11L, 21L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void measurementDeclarationUsesOrderReferenceAndSumsCargoFacts() {
|
||||
OrderEntity order = new OrderEntity();
|
||||
order.setId(21L); order.setTenantId(11L); order.setReferenceId("REF-50");
|
||||
OrderCargoEntity first = cargo(31L, 1, 60, "60", "40", "30");
|
||||
first.setCargoName("品名A"); first.setGrossWeightKg(new BigDecimal("1200"));
|
||||
first.setVolumeCbm(new BigDecimal("12.5"));
|
||||
OrderCargoEntity second = cargo(32L, 2, 40, "50", "30", "20");
|
||||
second.setCargoName("品名B"); second.setGrossWeightKg(new BigDecimal("800"));
|
||||
second.setVolumeCbm(new BigDecimal("7.5"));
|
||||
when(orders.findOwned(11L, 21L)).thenReturn(order);
|
||||
when(orders.findCargo(11L, 21L)).thenReturn(List.of(first, second));
|
||||
|
||||
var result = controller.measurementDeclaration("internal-token", 11L, 21L).data();
|
||||
|
||||
assertThat(result.referenceId()).isEqualTo("REF-50");
|
||||
assertThat(result.originalGrossWeightKg()).isEqualByComparingTo("2000");
|
||||
assertThat(result.originalVolumeCbm()).isEqualByComparingTo("20.0");
|
||||
assertThat(result.defaultActualGoodsName()).isEqualTo("品名A、品名B");
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitChildWithoutDirectCargoDoesNotCopyRootDeclarationTotals() {
|
||||
OrderEntity child = new OrderEntity();
|
||||
child.setId(201L); child.setTenantId(11L); child.setOrderRole("CHILD"); child.setRootOrderId(21L);
|
||||
when(orders.findOwned(11L, 201L)).thenReturn(child);
|
||||
when(orders.findCargo(11L, 201L)).thenReturn(List.of());
|
||||
|
||||
var result = controller.measurementDeclaration("internal-token", 11L, 201L).data();
|
||||
|
||||
assertThat(result.originalGrossWeightKg()).isNull();
|
||||
assertThat(result.originalVolumeCbm()).isNull();
|
||||
assertThat(result.defaultActualGoodsName()).isEmpty();
|
||||
verify(orders).findCargo(11L, 201L);
|
||||
}
|
||||
|
||||
private OrderCargoEntity cargo(long id, int itemNo, int packageCount,
|
||||
String length, String width, String height) {
|
||||
OrderCargoEntity cargo = new OrderCargoEntity();
|
||||
|
||||
@@ -55,7 +55,7 @@ class UnifiedOrderApiContractTest {
|
||||
"loading", "delivery", "transport", "customerRemark", "latestInternalNote",
|
||||
"expectedArrivalAt", "requestedPickupAt", "feeReviewStatus", "companyName", "businessType", "pickupMethod",
|
||||
"taxMode", "containerMode", "customsInspection", "destinationType", "destinationCode",
|
||||
"totalPalletCount", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg",
|
||||
"totalPalletCount", "chargeableWeightKg", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg",
|
||||
"measurementCompletedAt", "inboundCompletedAt", "expressCompany", "expressTrackingNo",
|
||||
"createdBy", "skuNos", "totalQuantity", "shipmentId", "referenceId");
|
||||
assertThat(responseSchema(paths, "/api/platform/v1/orders", "get"))
|
||||
|
||||
@@ -252,6 +252,42 @@ class OrderApplicationServiceStateTest {
|
||||
verify(repository).saveOrder(20002L, order);
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyManualModeStoresTheExternalInboundCodeOnTheDraft() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
|
||||
OrderRequests.SaveRequest request = withInboundCode(request(), "nyexx2608140001");
|
||||
when(masterData.validate(10001L, request)).thenReturn(snapshots());
|
||||
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
|
||||
OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData);
|
||||
ReflectionTestUtils.setField(service, "inboundCodeMode", "LEGACY_MANUAL");
|
||||
|
||||
service.createDraft(10001L, 2L, request, "legacy-code-draft");
|
||||
|
||||
verify(repository).insertOrder(argThat(order -> "NYEXX2608140001".equals(order.getInboundCode())));
|
||||
verify(repository, never()).nextInboundCodeSequence(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyManualModeRejectsSubmissionWithoutAnExternalInboundCode() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS");
|
||||
when(repository.findOwned(20002L, 1L)).thenReturn(order);
|
||||
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(completeCargo()));
|
||||
when(repository.findParties(20002L, 1L)).thenReturn(List.of());
|
||||
OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class),
|
||||
mock(OrderMasterDataGuard.class));
|
||||
ReflectionTestUtils.setField(service, "inboundCodeMode", "LEGACY_MANUAL");
|
||||
|
||||
assertThatThrownBy(() -> service.transit(
|
||||
20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("当前系统使用旧系统入仓码,请先填写入仓码");
|
||||
|
||||
verify(repository, never()).nextInboundCodeSequence(any());
|
||||
verify(repository, never()).saveOrder(anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void submittingLegacyDraftRequiresPricingTaxMode() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
@@ -531,6 +567,18 @@ class OrderApplicationServiceStateTest {
|
||||
"BUY_DECLARATION", "TAX_INCLUDED", "LCL", false, "OVERSEAS", null, null, null);
|
||||
}
|
||||
|
||||
private OrderRequests.SaveRequest withInboundCode(OrderRequests.SaveRequest source, String inboundCode) {
|
||||
return new OrderRequests.SaveRequest(
|
||||
source.customerId(), source.customerName(), source.transportMode(), source.tradeTerm(),
|
||||
source.destinationCountryCode(), source.destinationWarehouseId(), source.destinationWarehouseName(),
|
||||
source.expectedShipDate(), source.customerRemark(), source.sender(), source.receiver(), source.cargoes(),
|
||||
source.pickupMethod(), source.requestedPickupAt(), source.receivingWarehouseId(),
|
||||
source.receivingWarehouseName(), source.channelCode(), source.channelName(), source.carrierId(),
|
||||
source.carrierName(), source.taxMode(), source.pricingTaxMode(), source.containerMode(),
|
||||
source.customsInspection(), source.destinationType(), source.amazonWarehouseCode(), source.shipmentId(),
|
||||
source.referenceId(), inboundCode);
|
||||
}
|
||||
|
||||
private OrderRequests.SaveRequest request(Long customerId, String pickupMethod,
|
||||
OrderRequests.Party sender, OrderRequests.Party receiver,
|
||||
String destinationType) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.client.FirstlegUnifiedOrderClient;
|
||||
import com.baiyue.order.dto.UnifiedOrderQuery;
|
||||
import com.baiyue.order.dto.UnifiedOrderResponse;
|
||||
import com.baiyue.order.entity.OrderCargoEntity;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.entity.OrderInternalNoteEntity;
|
||||
import com.baiyue.order.entity.OrderStatus;
|
||||
@@ -19,6 +21,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
@@ -74,6 +77,71 @@ class UnifiedOrderQueryServiceTest {
|
||||
verify(firstleg, never()).summaries(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void newlyCreatedOrderUsesItsOwnCargoPackageCountBeforeInboundExists() {
|
||||
UnifiedOrderQuery query = query(null);
|
||||
OrderEntity order = order(OrderStatus.APPROVED);
|
||||
OrderCargoEntity cargo = new OrderCargoEntity();
|
||||
cargo.setOrderId(77L);
|
||||
cargo.setPackageCount(100);
|
||||
when(orders.unifiedPage(eq(10001L), eq(query), any()))
|
||||
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
|
||||
when(firstleg.summaries(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of()));
|
||||
when(orders.findCargoByOrderIds(10001L, List.of(77L))).thenReturn(List.of(cargo));
|
||||
|
||||
assertThat(service.page(10001L, query).records()).singleElement()
|
||||
.extracting(UnifiedOrderResponse.Row::totalPackageCount).isEqualTo(100);
|
||||
verify(orders).findCargoByOrderIds(10001L, List.of(77L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void inboundPackageCountRemainsTheFulfillmentFactAfterSplit() {
|
||||
UnifiedOrderQuery query = query(null);
|
||||
OrderEntity child = order(OrderStatus.APPROVED);
|
||||
child.setParentOrderId(66L);
|
||||
OrderCargoEntity inheritedOriginal = new OrderCargoEntity();
|
||||
inheritedOriginal.setOrderId(77L);
|
||||
inheritedOriginal.setPackageCount(100);
|
||||
var summary = summary("LOADING", null);
|
||||
when(summary.inbound().totalPackageCount()).thenReturn(35);
|
||||
when(orders.unifiedPage(eq(10001L), eq(query), any()))
|
||||
.thenReturn(new PageResult<>(List.of(child), 1, 20, 1, 1));
|
||||
when(firstleg.summaries(eq("internal-token"), any()))
|
||||
.thenReturn(ApiResponse.success(List.of(summary)));
|
||||
when(orders.findCargoByOrderIds(10001L, List.of(77L))).thenReturn(List.of(inheritedOriginal));
|
||||
|
||||
assertThat(service.page(10001L, query).records()).singleElement()
|
||||
.extracting(UnifiedOrderResponse.Row::totalPackageCount).isEqualTo(35);
|
||||
}
|
||||
|
||||
@Test
|
||||
void inboundMasterUsesTotalPackageCountEvenWhenLegacyPackageCountIsAbsent() {
|
||||
UnifiedOrderQuery query = query(null);
|
||||
OrderEntity order = order(OrderStatus.APPROVED);
|
||||
var summary = summary("WAIT_MEASURE", null);
|
||||
when(summary.inbound().totalPackageCount()).thenReturn(100);
|
||||
when(orders.unifiedPage(eq(10001L), eq(query), any()))
|
||||
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
|
||||
when(firstleg.summaries(eq("internal-token"), any()))
|
||||
.thenReturn(ApiResponse.success(List.of(summary)));
|
||||
|
||||
assertThat(service.page(10001L, query).records()).singleElement()
|
||||
.extracting(UnifiedOrderResponse.Row::totalPackageCount).isEqualTo(100);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingCargoAndInboundReturnsNullInsteadOfFabricatedZero() {
|
||||
UnifiedOrderQuery query = query(null);
|
||||
OrderEntity order = order(OrderStatus.APPROVED);
|
||||
when(orders.unifiedPage(eq(10001L), eq(query), any()))
|
||||
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
|
||||
when(firstleg.summaries(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of()));
|
||||
when(orders.findCargoByOrderIds(10001L, List.of(77L))).thenReturn(List.of());
|
||||
|
||||
assertThat(service.page(10001L, query).records()).singleElement()
|
||||
.extracting(UnifiedOrderResponse.Row::totalPackageCount).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingReviewInboundCodeFilterStaysInOrderQuery() {
|
||||
UnifiedOrderQuery query = new UnifiedOrderQuery(
|
||||
@@ -173,10 +241,27 @@ class UnifiedOrderQueryServiceTest {
|
||||
assertThat(row.companyName()).isEqualTo("测试客户有限公司");
|
||||
assertThat(row.businessType()).isEqualTo("SEA_LCL");
|
||||
assertThat(row.totalPalletCount()).isEqualTo(2);
|
||||
assertThat(row.chargeableWeightKg()).isEqualByComparingTo("125.500");
|
||||
assertThat(row.actualGrossWeightKg()).isEqualByComparingTo("1787.500");
|
||||
assertThat(row.totalNetWeightKg()).isEqualByComparingTo("1747.500");
|
||||
});
|
||||
verify(orders, never()).unifiedPage(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedRowDoesNotUseGrossWeightAsNetWeightFallback() {
|
||||
UnifiedOrderQuery query = query("INBOUNDED");
|
||||
var summary = summary("INBOUNDED", null);
|
||||
when(summary.inbound().actualNetWeightKg()).thenReturn(null);
|
||||
when(firstleg.page(eq("internal-token"), any())).thenReturn(
|
||||
ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
|
||||
|
||||
var row = service.page(null, query).records().get(0);
|
||||
|
||||
assertThat(row.actualGrossWeightKg()).isEqualByComparingTo("1787.500");
|
||||
assertThat(row.totalNetWeightKg()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void waitSplitFilterForwardsAndReturnsWaitSplitIndependently() {
|
||||
UnifiedOrderQuery query = query("WAIT_SPLIT");
|
||||
@@ -201,6 +286,9 @@ class UnifiedOrderQueryServiceTest {
|
||||
UnifiedOrderQuery query = query(null);
|
||||
OrderEntity order = order(OrderStatus.APPROVED);
|
||||
var summary = summary("LOADED", null);
|
||||
when(summary.inbound().estimatedSailingAt()).thenReturn(LocalDateTime.of(2026, 8, 20, 8, 0));
|
||||
when(summary.inbound().actualSailingAt()).thenReturn(LocalDateTime.of(2026, 8, 21, 9, 0));
|
||||
when(summary.inbound().estimatedPortArrivalAt()).thenReturn(LocalDateTime.of(2026, 9, 1, 8, 0));
|
||||
when(orders.unifiedPage(eq(10001L), eq(query), any()))
|
||||
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
|
||||
when(firstleg.summaries(eq("internal-token"), any()))
|
||||
@@ -212,6 +300,10 @@ class UnifiedOrderQueryServiceTest {
|
||||
assertThat(row.mainStatus()).isEqualTo("LOADED");
|
||||
assertThat(row.internalOrderStatus()).isEqualTo(OrderStatus.APPROVED);
|
||||
assertThat(row.inboundId()).isEqualTo(88L);
|
||||
assertThat(row.transport().effectiveSailingAt()).isEqualTo(LocalDateTime.of(2026, 8, 21, 9, 0));
|
||||
assertThat(row.transport().effectiveSailingAtEstimated()).isFalse();
|
||||
assertThat(row.transport().effectivePortArrivalAt()).isEqualTo(LocalDateTime.of(2026, 9, 1, 8, 0));
|
||||
assertThat(row.transport().effectivePortArrivalAtEstimated()).isTrue();
|
||||
});
|
||||
ArgumentCaptor<FirstlegUnifiedOrderClient.BatchRequest> captor =
|
||||
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.BatchRequest.class);
|
||||
@@ -349,6 +441,9 @@ class UnifiedOrderQueryServiceTest {
|
||||
when(inbound.companyName()).thenReturn("测试客户有限公司");
|
||||
when(inbound.businessType()).thenReturn("SEA_LCL");
|
||||
when(inbound.palletCount()).thenReturn(2);
|
||||
when(inbound.actualGrossWeightKg()).thenReturn(new BigDecimal("1787.500"));
|
||||
when(inbound.actualNetWeightKg()).thenReturn(new BigDecimal("1747.500"));
|
||||
when(inbound.chargeableWeightKg()).thenReturn(new BigDecimal("125.500"));
|
||||
when(inbound.createdAt()).thenReturn(LocalDateTime.of(2026, 8, 6, 10, 0));
|
||||
when(inbound.updatedAt()).thenReturn(LocalDateTime.of(2026, 8, 6, 11, 0));
|
||||
return new FirstlegUnifiedOrderClient.Summary(inbound, status, action,
|
||||
|
||||
Reference in New Issue
Block a user