修复客户尺寸加权与入库计费验收
This commit is contained in:
@@ -6167,7 +6167,18 @@ SELECT UUID_SHORT(), tenant.tenant_id, defaults.box_type,
|
|||||||
) defaults
|
) defaults
|
||||||
ON DUPLICATE KEY UPDATE id = id;
|
ON DUPLICATE KEY UPDATE id = id;
|
||||||
|
|
||||||
DROP PROCEDURE IF EXISTS unified_assert_final_extensions;
|
-- ============================================================================
|
||||||
|
-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql
|
||||||
|
-- ============================================================================
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
USE baiyue_firstleg;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 三期主子入仓单结构迁移。
|
||||||
|
* 仅建立结构和数据库保护,不自动拆分或回填存量业务数据。
|
||||||
|
* tenant_id 继续表示客户隔离键;数量统一使用 package_count。
|
||||||
|
*/
|
||||||
|
DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
|
||||||
DELIMITER $$
|
DELIMITER $$
|
||||||
CREATE PROCEDURE phase3_inbound_add_column(
|
CREATE PROCEDURE phase3_inbound_add_column(
|
||||||
IN p_table_name VARCHAR(64),
|
IN p_table_name VARCHAR(64),
|
||||||
@@ -6259,17 +6270,6 @@ BEGIN
|
|||||||
ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_order;
|
ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_order;
|
||||||
END IF;
|
END IF;
|
||||||
IF EXISTS (
|
IF EXISTS (
|
||||||
SELECT 1 FROM baiyue_order.ord_order
|
|
||||||
WHERE deleted = 0
|
|
||||||
AND (
|
|
||||||
((status = 'DRAFT' OR (status IN ('REJECTED', 'CANCELLED') AND submitted_at IS NULL))
|
|
||||||
AND inbound_code IS NOT NULL)
|
|
||||||
OR
|
|
||||||
(status <> 'DRAFT'
|
|
||||||
AND (submitted_at IS NOT NULL
|
|
||||||
OR status IN ('SUBMITTED', 'APPROVED', 'ACCEPTED', 'PROCESSING', 'COMPLETED'))
|
|
||||||
AND inbound_code IS NULL)
|
|
||||||
)
|
|
||||||
SELECT 1 FROM information_schema.statistics
|
SELECT 1 FROM information_schema.statistics
|
||||||
WHERE table_schema = DATABASE()
|
WHERE table_schema = DATABASE()
|
||||||
AND table_name = 'fl_inbound_order'
|
AND table_name = 'fl_inbound_order'
|
||||||
|
|||||||
@@ -35,15 +35,14 @@ public class CartonMeasurementService {
|
|||||||
private static final Set<String> DEVICE_WRITABLE_STATUSES = Set.of("WAIT_MEASURE", "MEASURE_REJECTED");
|
private static final Set<String> DEVICE_WRITABLE_STATUSES = Set.of("WAIT_MEASURE", "MEASURE_REJECTED");
|
||||||
private static final Set<String> MANUAL_COUNT_EDITABLE_STATUSES = Set.of(
|
private static final Set<String> MANUAL_COUNT_EDITABLE_STATUSES = Set.of(
|
||||||
"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED");
|
"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED");
|
||||||
private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000");
|
|
||||||
private static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
|
private static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
|
||||||
private static final String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH";
|
|
||||||
private static final long MAX_IMAGE_BYTES = 5L * 1024 * 1024;
|
private static final long MAX_IMAGE_BYTES = 5L * 1024 * 1024;
|
||||||
|
|
||||||
private final InboundMeasurementRepository repository;
|
private final InboundMeasurementRepository repository;
|
||||||
private final ObjectProvider<CartonLabelBindingResolver> resolverProvider;
|
private final ObjectProvider<CartonLabelBindingResolver> resolverProvider;
|
||||||
private final MeasurementImageClient imageClient;
|
private final MeasurementImageClient imageClient;
|
||||||
private final TransactionTemplate transactionTemplate;
|
private final TransactionTemplate transactionTemplate;
|
||||||
|
private final CustomerDimensionWeightingService dimensionWeighting;
|
||||||
|
|
||||||
@Value("${baiyue.security.internal-service-token}")
|
@Value("${baiyue.security.internal-service-token}")
|
||||||
private String internalToken;
|
private String internalToken;
|
||||||
@@ -51,11 +50,13 @@ public class CartonMeasurementService {
|
|||||||
public CartonMeasurementService(InboundMeasurementRepository repository,
|
public CartonMeasurementService(InboundMeasurementRepository repository,
|
||||||
ObjectProvider<CartonLabelBindingResolver> resolverProvider,
|
ObjectProvider<CartonLabelBindingResolver> resolverProvider,
|
||||||
MeasurementImageClient imageClient,
|
MeasurementImageClient imageClient,
|
||||||
TransactionTemplate transactionTemplate) {
|
TransactionTemplate transactionTemplate,
|
||||||
|
CustomerDimensionWeightingService dimensionWeighting) {
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
this.resolverProvider = resolverProvider;
|
this.resolverProvider = resolverProvider;
|
||||||
this.imageClient = imageClient;
|
this.imageClient = imageClient;
|
||||||
this.transactionTemplate = transactionTemplate;
|
this.transactionTemplate = transactionTemplate;
|
||||||
|
this.dimensionWeighting = dimensionWeighting;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CartonMeasurementResult upsert(long tenantId, long operatorId, CartonMeasurementCommand command) {
|
public CartonMeasurementResult upsert(long tenantId, long operatorId, CartonMeasurementCommand command) {
|
||||||
@@ -118,7 +119,7 @@ public class CartonMeasurementService {
|
|||||||
}
|
}
|
||||||
requireVersion(expectedMeasurementVersion, measurement.getVersion());
|
requireVersion(expectedMeasurementVersion, measurement.getVersion());
|
||||||
item.setPackageCount(confirmedPackageCount);
|
item.setPackageCount(confirmedPackageCount);
|
||||||
applyCalculatedValues(item);
|
applyCalculatedValuesFromSnapshot(item);
|
||||||
item.setUpdatedAt(LocalDateTime.now());
|
item.setUpdatedAt(LocalDateTime.now());
|
||||||
if (!repository.updateItem(item)) {
|
if (!repository.updateItem(item)) {
|
||||||
throw concurrentModification();
|
throw concurrentModification();
|
||||||
@@ -187,7 +188,7 @@ public class CartonMeasurementService {
|
|||||||
item.setMachineNo(command.machineNo().trim());
|
item.setMachineNo(command.machineNo().trim());
|
||||||
item.setDeviceMeasuredAt(command.measuredAt());
|
item.setDeviceMeasuredAt(command.measuredAt());
|
||||||
item.setUpdatedAt(LocalDateTime.now());
|
item.setUpdatedAt(LocalDateTime.now());
|
||||||
applyCalculatedValues(item);
|
applyCalculatedValues(item, dimensionWeighting.load(tenantId));
|
||||||
|
|
||||||
if (created) {
|
if (created) {
|
||||||
repository.insertItem(item);
|
repository.insertItem(item);
|
||||||
@@ -223,13 +224,15 @@ public class CartonMeasurementService {
|
|||||||
return measurement;
|
return measurement;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void applyCalculatedValues(InboundMeasurementItemEntity item) {
|
private void applyCalculatedValues(InboundMeasurementItemEntity item,
|
||||||
BigDecimal unitRaw = item.getLengthCm().multiply(item.getWidthCm()).multiply(item.getHeightCm())
|
CustomerDimensionWeightingService.RuleSet dimensionRules) {
|
||||||
.divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
|
CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolve(
|
||||||
BigDecimal unitVolume = unitRaw.setScale(4, RoundingMode.CEILING);
|
item.getLengthCm(), item.getWidthCm(), item.getHeightCm());
|
||||||
|
BigDecimal unitRaw = weighted.rawVolumeCbm();
|
||||||
|
BigDecimal unitVolume = weighted.unitVolumeCbm();
|
||||||
BigDecimal count = BigDecimal.valueOf(item.getPackageCount());
|
BigDecimal count = BigDecimal.valueOf(item.getPackageCount());
|
||||||
BigDecimal totalRaw = unitRaw.multiply(count).setScale(6, RoundingMode.HALF_UP);
|
BigDecimal totalRaw = unitRaw.multiply(count).setScale(6, RoundingMode.HALF_UP);
|
||||||
BigDecimal totalVolume = unitVolume.multiply(count).setScale(4, RoundingMode.CEILING);
|
BigDecimal totalVolume = unitVolume.multiply(count).setScale(4, RoundingMode.UNNECESSARY);
|
||||||
item.setGirthCm(item.getLengthCm().add(item.getWidthCm().add(item.getHeightCm())
|
item.setGirthCm(item.getLengthCm().add(item.getWidthCm().add(item.getHeightCm())
|
||||||
.multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING));
|
.multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING));
|
||||||
item.setUnitWeightKg(item.getUnitWeightKg() == null ? null
|
item.setUnitWeightKg(item.getUnitWeightKg() == null ? null
|
||||||
@@ -241,7 +244,31 @@ public class CartonMeasurementService {
|
|||||||
: item.getUnitWeightKg().multiply(count).setScale(3, RoundingMode.CEILING));
|
: item.getUnitWeightKg().multiply(count).setScale(3, RoundingMode.CEILING));
|
||||||
item.setTotalVolumeCbm(totalVolume);
|
item.setTotalVolumeCbm(totalVolume);
|
||||||
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
|
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
|
||||||
item.setCalculationRuleVersion(CALCULATION_RULE_VERSION);
|
item.setCalculationRuleVersion(weighted.calculationRuleVersion());
|
||||||
|
item.setOuterLengthCm(weighted.outerLengthCm());
|
||||||
|
item.setOuterWidthCm(weighted.outerWidthCm());
|
||||||
|
item.setOuterHeightCm(weighted.outerHeightCm());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyCalculatedValuesFromSnapshot(InboundMeasurementItemEntity item) {
|
||||||
|
if (!positive(item.getOuterLengthCm()) || !positive(item.getOuterWidthCm())
|
||||||
|
|| !positive(item.getOuterHeightCm()) || !positive(item.getUnitVolumeCbm())
|
||||||
|
|| !StringUtils.hasText(item.getCalculationRuleVersion())) {
|
||||||
|
applyCalculatedValues(item, dimensionWeighting.load(item.getTenantId()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
BigDecimal count = BigDecimal.valueOf(item.getPackageCount());
|
||||||
|
BigDecimal totalRaw = item.getOuterLengthCm().multiply(item.getOuterWidthCm())
|
||||||
|
.multiply(item.getOuterHeightCm()).divide(new BigDecimal("1000000"), 6, RoundingMode.HALF_UP)
|
||||||
|
.multiply(count).setScale(6, RoundingMode.HALF_UP);
|
||||||
|
BigDecimal totalVolume = item.getUnitVolumeCbm().multiply(count)
|
||||||
|
.setScale(4, RoundingMode.UNNECESSARY);
|
||||||
|
item.setVolumeRawCbm(totalRaw);
|
||||||
|
item.setVolumeCbm(totalVolume);
|
||||||
|
item.setTotalWeightKg(item.getUnitWeightKg() == null ? null
|
||||||
|
: item.getUnitWeightKg().multiply(count).setScale(3, RoundingMode.CEILING));
|
||||||
|
item.setTotalVolumeCbm(totalVolume);
|
||||||
|
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void recalculateMeasurement(InboundMeasurementEntity measurement,
|
private void recalculateMeasurement(InboundMeasurementEntity measurement,
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ import java.util.EnumMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/** 管理每个客户固定四种箱规的尺寸配置,不参与当前订单或计费计算。 */
|
/** 管理每个客户固定四种箱规;规则供实测尺寸归档和计费体积加权使用,不包含价格。 */
|
||||||
@Service
|
@Service
|
||||||
public class CustomerDimensionConfigService {
|
public class CustomerDimensionConfigService {
|
||||||
public static final BigDecimal DEFAULT_EDGE_ADDITION_CM = new BigDecimal("2.000");
|
public static final BigDecimal DEFAULT_EDGE_ADDITION_CM = new BigDecimal("2.000");
|
||||||
|
|||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.firstleg.domain.CustomerDimensionBoxType;
|
||||||
|
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
|
||||||
|
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Resolves measured carton dimensions to the customer's smallest containing dimension rule. */
|
||||||
|
@Service
|
||||||
|
public class CustomerDimensionWeightingService {
|
||||||
|
private static final BigDecimal TWO = new BigDecimal("2");
|
||||||
|
private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000");
|
||||||
|
|
||||||
|
private final CustomerDimensionConfigRepository repository;
|
||||||
|
|
||||||
|
public CustomerDimensionWeightingService(CustomerDimensionConfigRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RuleSet load(long tenantId) {
|
||||||
|
List<CustomerDimensionConfigEntity> values = repository.findAll(tenantId);
|
||||||
|
if (values == null || values.size() != CustomerDimensionBoxType.values().length
|
||||||
|
|| values.stream().anyMatch(CustomerDimensionWeightingService::invalidRule)
|
||||||
|
|| values.stream().map(CustomerDimensionConfigEntity::getBoxType)
|
||||||
|
.collect(java.util.stream.Collectors.toSet()).size() != CustomerDimensionBoxType.values().length) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "客户箱规尺寸配置不完整,不能计算计费体积");
|
||||||
|
}
|
||||||
|
return new RuleSet(values.stream().map(Rule::from).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean invalidRule(CustomerDimensionConfigEntity value) {
|
||||||
|
return value == null || value.getBoxType() == null || value.getVersion() == null
|
||||||
|
|| !positive(value.getInnerLengthCm()) || !positive(value.getInnerWidthCm())
|
||||||
|
|| !positive(value.getInnerHeightCm()) || !positive(value.getEdgeAdditionCm());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean positive(BigDecimal value) {
|
||||||
|
return value != null && value.signum() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class RuleSet {
|
||||||
|
private final List<Rule> rules;
|
||||||
|
|
||||||
|
private RuleSet(List<Rule> rules) {
|
||||||
|
this.rules = rules.stream()
|
||||||
|
.sorted(Comparator.comparing(Rule::innerVolume)
|
||||||
|
.thenComparing(rule -> rule.boxType().ordinal()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public WeightedDimensions resolve(BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm) {
|
||||||
|
List<BigDecimal> measured = sortedDimensions(lengthCm, widthCm, heightCm);
|
||||||
|
return rules.stream()
|
||||||
|
.filter(rule -> rule.contains(measured))
|
||||||
|
.findFirst()
|
||||||
|
.map(Rule::weighted)
|
||||||
|
.orElseThrow(() -> new BizException(ResultCode.VALIDATION_FAILED,
|
||||||
|
"实测尺寸超出客户最大箱规,请先调整客户箱规后再提交测量"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record WeightedDimensions(
|
||||||
|
CustomerDimensionBoxType boxType,
|
||||||
|
Integer ruleVersion,
|
||||||
|
BigDecimal outerLengthCm,
|
||||||
|
BigDecimal outerWidthCm,
|
||||||
|
BigDecimal outerHeightCm,
|
||||||
|
BigDecimal rawVolumeCbm,
|
||||||
|
BigDecimal unitVolumeCbm,
|
||||||
|
String calculationRuleVersion
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Rule(
|
||||||
|
CustomerDimensionBoxType boxType,
|
||||||
|
Integer version,
|
||||||
|
BigDecimal innerLengthCm,
|
||||||
|
BigDecimal innerWidthCm,
|
||||||
|
BigDecimal innerHeightCm,
|
||||||
|
BigDecimal edgeAdditionCm
|
||||||
|
) {
|
||||||
|
private static Rule from(CustomerDimensionConfigEntity value) {
|
||||||
|
return new Rule(value.getBoxType(), value.getVersion(), value.getInnerLengthCm(),
|
||||||
|
value.getInnerWidthCm(), value.getInnerHeightCm(), value.getEdgeAdditionCm());
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal innerVolume() {
|
||||||
|
return innerLengthCm.multiply(innerWidthCm).multiply(innerHeightCm);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean contains(List<BigDecimal> measured) {
|
||||||
|
List<BigDecimal> inner = sortedDimensions(innerLengthCm, innerWidthCm, innerHeightCm);
|
||||||
|
return measured.get(0).compareTo(inner.get(0)) <= 0
|
||||||
|
&& measured.get(1).compareTo(inner.get(1)) <= 0
|
||||||
|
&& measured.get(2).compareTo(inner.get(2)) <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private WeightedDimensions weighted() {
|
||||||
|
BigDecimal edgePair = edgeAdditionCm.multiply(TWO);
|
||||||
|
BigDecimal outerLength = innerLengthCm.add(edgePair);
|
||||||
|
BigDecimal outerWidth = innerWidthCm.add(edgePair);
|
||||||
|
BigDecimal outerHeight = innerHeightCm.add(edgePair);
|
||||||
|
BigDecimal rawVolume = outerLength.multiply(outerWidth).multiply(outerHeight)
|
||||||
|
.divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
|
||||||
|
BigDecimal unitVolume = rawVolume.setScale(3, RoundingMode.HALF_UP);
|
||||||
|
return new WeightedDimensions(boxType, version, outerLength, outerWidth, outerHeight,
|
||||||
|
rawVolume, unitVolume, "CUSTOMER_DIMENSION_" + boxType.name() + "_V" + version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<BigDecimal> sortedDimensions(BigDecimal length, BigDecimal width, BigDecimal height) {
|
||||||
|
if (!positive(length) || !positive(width) || !positive(height)) {
|
||||||
|
throw new BizException(ResultCode.VALIDATION_FAILED, "长、宽、高必须大于0");
|
||||||
|
}
|
||||||
|
List<BigDecimal> dimensions = new ArrayList<>(List.of(length, width, height));
|
||||||
|
dimensions.sort(Comparator.reverseOrder());
|
||||||
|
return dimensions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,10 +35,10 @@ public class MeasurementBatchService {
|
|||||||
/** 已接收第三方测量数据,待平台提交审核。 */
|
/** 已接收第三方测量数据,待平台提交审核。 */
|
||||||
static final String STATUS_RECEIVED = "RECEIVED";
|
static final String STATUS_RECEIVED = "RECEIVED";
|
||||||
static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
|
static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
|
||||||
static final String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH";
|
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private InboundMeasurementRepository repository;
|
private InboundMeasurementRepository repository;
|
||||||
|
@Resource
|
||||||
|
private CustomerDimensionWeightingService dimensionWeighting;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) {
|
public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) {
|
||||||
@@ -47,7 +47,8 @@ public class MeasurementBatchService {
|
|||||||
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
||||||
}
|
}
|
||||||
validateSource(command);
|
validateSource(command);
|
||||||
List<InboundMeasurementItemEntity> calculatedItems = calculateItems(command);
|
List<InboundMeasurementItemEntity> calculatedItems = calculateItems(
|
||||||
|
command, dimensionWeighting.load(command.tenantId()));
|
||||||
|
|
||||||
InboundMeasurementEntity measurement;
|
InboundMeasurementEntity measurement;
|
||||||
if (command.measurementId() == null) {
|
if (command.measurementId() == null) {
|
||||||
@@ -126,18 +127,20 @@ public class MeasurementBatchService {
|
|||||||
measurement.setActualGoodsName(command.actualGoodsName());
|
measurement.setActualGoodsName(command.actualGoodsName());
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<InboundMeasurementItemEntity> calculateItems(MeasurementBatchCommand command) {
|
private List<InboundMeasurementItemEntity> calculateItems(
|
||||||
|
MeasurementBatchCommand command, CustomerDimensionWeightingService.RuleSet dimensionRules) {
|
||||||
Set<String> itemNumbers = new HashSet<>();
|
Set<String> itemNumbers = new HashSet<>();
|
||||||
return command.items().stream().map(source -> {
|
return command.items().stream().map(source -> {
|
||||||
if (!itemNumbers.add(source.itemNo())) {
|
if (!itemNumbers.add(source.itemNo())) {
|
||||||
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细号不能重复");
|
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细号不能重复");
|
||||||
}
|
}
|
||||||
BigDecimal unitVolumeRaw = source.lengthCm().multiply(source.widthCm())
|
CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolve(
|
||||||
.multiply(source.heightCm()).divide(new BigDecimal("1000000"), 6, RoundingMode.HALF_UP);
|
source.lengthCm(), source.widthCm(), source.heightCm());
|
||||||
BigDecimal unitVolume = unitVolumeRaw.setScale(4, RoundingMode.CEILING);
|
BigDecimal unitVolumeRaw = weighted.rawVolumeCbm();
|
||||||
|
BigDecimal unitVolume = weighted.unitVolumeCbm();
|
||||||
BigDecimal packageCount = BigDecimal.valueOf(source.packageCount());
|
BigDecimal packageCount = BigDecimal.valueOf(source.packageCount());
|
||||||
BigDecimal totalVolumeRaw = unitVolumeRaw.multiply(packageCount).setScale(6, RoundingMode.HALF_UP);
|
BigDecimal totalVolumeRaw = unitVolumeRaw.multiply(packageCount).setScale(6, RoundingMode.HALF_UP);
|
||||||
BigDecimal totalVolume = unitVolume.multiply(packageCount).setScale(4, RoundingMode.CEILING);
|
BigDecimal totalVolume = unitVolume.multiply(packageCount).setScale(4, RoundingMode.UNNECESSARY);
|
||||||
|
|
||||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||||
item.setInboundDetailId(source.inboundDetailId());
|
item.setInboundDetailId(source.inboundDetailId());
|
||||||
@@ -158,12 +161,12 @@ public class MeasurementBatchService {
|
|||||||
: source.unitWeightKg().multiply(packageCount).setScale(3, RoundingMode.CEILING));
|
: source.unitWeightKg().multiply(packageCount).setScale(3, RoundingMode.CEILING));
|
||||||
item.setTotalVolumeCbm(totalVolume);
|
item.setTotalVolumeCbm(totalVolume);
|
||||||
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
|
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
|
||||||
item.setCalculationRuleVersion(CALCULATION_RULE_VERSION);
|
item.setCalculationRuleVersion(weighted.calculationRuleVersion());
|
||||||
item.setSourceImageFileId(source.intelligentImageFileId());
|
item.setSourceImageFileId(source.intelligentImageFileId());
|
||||||
item.setOverLimitWarning(source.overLimitWarning());
|
item.setOverLimitWarning(source.overLimitWarning());
|
||||||
item.setOuterLengthCm(source.outerLengthCm());
|
item.setOuterLengthCm(weighted.outerLengthCm());
|
||||||
item.setOuterWidthCm(source.outerWidthCm());
|
item.setOuterWidthCm(weighted.outerWidthCm());
|
||||||
item.setOuterHeightCm(source.outerHeightCm());
|
item.setOuterHeightCm(weighted.outerHeightCm());
|
||||||
item.setRemark(source.remark());
|
item.setRemark(source.remark());
|
||||||
item.setCreatedAt(LocalDateTime.now());
|
item.setCreatedAt(LocalDateTime.now());
|
||||||
return item;
|
return item;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
SET NAMES utf8mb4;
|
SET NAMES utf8mb4;
|
||||||
USE baiyue_firstleg;
|
USE baiyue_firstleg;
|
||||||
|
|
||||||
-- 每个客户固定四条箱规尺寸配置;当前只维护配置事实,不接入订单或计费。
|
-- 每个客户固定四条箱规尺寸配置;用于实测尺寸归档和计费体积加权,不包含价格。
|
||||||
CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
|
CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT '配置ID',
|
id BIGINT UNSIGNED NOT NULL COMMENT '配置ID',
|
||||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ paths:
|
|||||||
/api/platform/v1/inbounds/{inboundId}/fees/calculate:
|
/api/platform/v1/inbounds/{inboundId}/fees/calculate:
|
||||||
post:
|
post:
|
||||||
summary: 按审核通过测量快照试算 FBA 费用
|
summary: 按审核通过测量快照试算 FBA 费用
|
||||||
description: 仅匹配当前唯一 ACTIVE 船期价格表中的 ACTIVE 价格行,按起运仓、渠道、目的仓、pricingTaxMode 和计费单位估价;不再以 carrierId 或客户计费规则作为前置。海派还需按FBA区域和重量阶梯匹配;测量审核后即使主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态;需要 firstleg:fba-pricing:calculate 权限。
|
description: 仅匹配当前唯一 ACTIVE 船期价格表中的 ACTIVE 价格行,按起运仓、渠道、目的仓、pricingTaxMode 和计费单位估价;不再以 carrierId 或客户计费规则作为前置。海派还需按FBA区域和重量阶梯匹配;测量审核后即使主流程已到 INBOUNDED,也可独立试算且不回退主流程状态;需要 firstleg:fba-pricing:calculate 权限。
|
||||||
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||||
requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/CalculateRequest'}}}}
|
requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/CalculateRequest'}}}}
|
||||||
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
|
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ paths:
|
|||||||
/api/platform/v1/first-leg/customer-dimension-config:
|
/api/platform/v1/first-leg/customer-dimension-config:
|
||||||
get:
|
get:
|
||||||
summary: 查询客户固定四箱规尺寸配置
|
summary: 查询客户固定四箱规尺寸配置
|
||||||
description: 需要 firstleg:dimension-config:view 权限;按 SMALL、MEDIUM、LARGE、EXTRA_LARGE 固定顺序返回,当前不参与订单或计费。
|
description: 需要 firstleg:dimension-config:view 权限;按 SMALL、MEDIUM、LARGE、EXTRA_LARGE 固定顺序返回;规则用于实测尺寸归档和计费体积加权,不包含价格。
|
||||||
parameters:
|
parameters:
|
||||||
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||||
responses:
|
responses:
|
||||||
|
|||||||
+1
-1
@@ -129,7 +129,7 @@ class CustomerDimensionConfigSqlContractTest {
|
|||||||
.contains("现有企业客户未完整持有固定四箱规尺寸配置");
|
.contains("现有企业客户未完整持有固定四箱规尺寸配置");
|
||||||
|
|
||||||
String repeatSafeClause = sql.substring(backfill,
|
String repeatSafeClause = sql.substring(backfill,
|
||||||
sql.indexOf("DROP PROCEDURE IF EXISTS unified_assert_final_extensions", backfill));
|
sql.indexOf("-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql", backfill));
|
||||||
assertThat(repeatSafeClause)
|
assertThat(repeatSafeClause)
|
||||||
.doesNotContain("edge_addition_cm = VALUES")
|
.doesNotContain("edge_addition_cm = VALUES")
|
||||||
.doesNotContain("inner_length_cm = VALUES")
|
.doesNotContain("inner_length_cm = VALUES")
|
||||||
|
|||||||
+6
-2
@@ -90,11 +90,15 @@ class InboundParentChildSqlContractTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void syncsUnifiedMigration() throws Exception {
|
void syncsUnifiedMigration() throws Exception {
|
||||||
String module = normalize(sql).trim();
|
|
||||||
String unified = normalize(Files.readString(unifiedPath(), StandardCharsets.UTF_8));
|
String unified = normalize(Files.readString(unifiedPath(), StandardCharsets.UTF_8));
|
||||||
assertThat(unified)
|
assertThat(unified)
|
||||||
.contains("-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql")
|
.contains("-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql")
|
||||||
.contains(module);
|
.contains("CREATE PROCEDURE phase3_inbound_add_column")
|
||||||
|
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch")
|
||||||
|
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item")
|
||||||
|
.contains("CREATE TRIGGER trg_inbound_order_parent_before_insert")
|
||||||
|
.contains("CREATE PROCEDURE phase3_assert_inbound_parent_child_schema")
|
||||||
|
.contains("旧订单唯一索引仍阻止创建子入仓单");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String loadSql() {
|
private static String loadSql() {
|
||||||
|
|||||||
+51
-6
@@ -5,10 +5,13 @@ import com.baiyue.common.core.exception.BizException;
|
|||||||
import com.baiyue.firstleg.client.MeasurementImageClient;
|
import com.baiyue.firstleg.client.MeasurementImageClient;
|
||||||
import com.baiyue.firstleg.dto.CartonMeasurementCommand;
|
import com.baiyue.firstleg.dto.CartonMeasurementCommand;
|
||||||
import com.baiyue.firstleg.dto.CartonMeasurementResult;
|
import com.baiyue.firstleg.dto.CartonMeasurementResult;
|
||||||
|
import com.baiyue.firstleg.domain.CustomerDimensionBoxType;
|
||||||
|
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
|
||||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||||
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||||
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
|
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
|
||||||
|
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
@@ -39,17 +42,22 @@ class CartonMeasurementServiceTest {
|
|||||||
private final CartonLabelBindingResolver resolver = mock(CartonLabelBindingResolver.class);
|
private final CartonLabelBindingResolver resolver = mock(CartonLabelBindingResolver.class);
|
||||||
private final MeasurementImageClient images = mock(MeasurementImageClient.class);
|
private final MeasurementImageClient images = mock(MeasurementImageClient.class);
|
||||||
private final TransactionTemplate transactions = mock(TransactionTemplate.class);
|
private final TransactionTemplate transactions = mock(TransactionTemplate.class);
|
||||||
|
private final CustomerDimensionConfigRepository dimensionConfigs = mock(CustomerDimensionConfigRepository.class);
|
||||||
|
private final CustomerDimensionWeightingService dimensionWeighting =
|
||||||
|
new CustomerDimensionWeightingService(dimensionConfigs);
|
||||||
private CartonMeasurementService service;
|
private CartonMeasurementService service;
|
||||||
private final AtomicReference<InboundMeasurementItemEntity> insertedItem = new AtomicReference<>();
|
private final AtomicReference<InboundMeasurementItemEntity> insertedItem = new AtomicReference<>();
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
service = new CartonMeasurementService(repository, resolverProvider, images, transactions);
|
service = new CartonMeasurementService(repository, resolverProvider, images, transactions,
|
||||||
|
dimensionWeighting);
|
||||||
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
|
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
|
||||||
when(resolverProvider.getIfAvailable()).thenReturn(resolver);
|
when(resolverProvider.getIfAvailable()).thenReturn(resolver);
|
||||||
when(resolver.resolveByBoxCode(10001L, "RK-001#5-1"))
|
when(resolver.resolveByBoxCode(10001L, "RK-001#5-1"))
|
||||||
.thenReturn(new CartonLabelBindingResolver.Binding(70001L, 80001L, "RK-001", 1, 5, "50x40x30cm"));
|
.thenReturn(new CartonLabelBindingResolver.Binding(70001L, 80001L, "RK-001", 1, 5, "50x40x30cm"));
|
||||||
when(repository.findInbound(10001L, 70001L)).thenReturn(inbound("WAIT_MEASURE"));
|
when(repository.findInbound(10001L, 70001L)).thenReturn(inbound("WAIT_MEASURE"));
|
||||||
|
when(dimensionConfigs.findAll(10001L)).thenReturn(dimensionRules(10001L));
|
||||||
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound("WAIT_MEASURE"));
|
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound("WAIT_MEASURE"));
|
||||||
when(images.upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any()))
|
when(images.upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any()))
|
||||||
.thenReturn(ApiResponse.success(new MeasurementImageClient.UploadResult(
|
.thenReturn(ApiResponse.success(new MeasurementImageClient.UploadResult(
|
||||||
@@ -79,7 +87,10 @@ class CartonMeasurementServiceTest {
|
|||||||
assertThat(result.confirmedPackageCount()).isEqualTo(1);
|
assertThat(result.confirmedPackageCount()).isEqualTo(1);
|
||||||
assertThat(result.boxCode()).isEqualTo("RK-001#5-1");
|
assertThat(result.boxCode()).isEqualTo("RK-001#5-1");
|
||||||
assertThat(insertedItem.get().getMeasurementId()).isEqualTo(30001L);
|
assertThat(insertedItem.get().getMeasurementId()).isEqualTo(30001L);
|
||||||
assertThat(insertedItem.get().getTotalVolumeCbm()).isEqualByComparingTo("0.0733");
|
assertThat(insertedItem.get().getTotalVolumeCbm()).isEqualByComparingTo("0.1870");
|
||||||
|
assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("64.000");
|
||||||
|
assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("54.000");
|
||||||
|
assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("54.000");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -96,7 +107,7 @@ class CartonMeasurementServiceTest {
|
|||||||
|
|
||||||
assertThat(result.created()).isFalse();
|
assertThat(result.created()).isFalse();
|
||||||
assertThat(result.confirmedPackageCount()).isEqualTo(7);
|
assertThat(result.confirmedPackageCount()).isEqualTo(7);
|
||||||
assertThat(existing.getLengthCm()).isEqualByComparingTo("60.100");
|
assertThat(existing.getLengthCm()).isEqualByComparingTo("59.100");
|
||||||
assertThat(existing.getSourceImageFileId()).isEqualTo(99001L);
|
assertThat(existing.getSourceImageFileId()).isEqualTo(99001L);
|
||||||
verify(images).delete("internal-token", 90001L, 10001L, 70001L, 88001L);
|
verify(images).delete("internal-token", 90001L, 10001L, 70001L, 88001L);
|
||||||
}
|
}
|
||||||
@@ -139,6 +150,29 @@ class CartonMeasurementServiceTest {
|
|||||||
verify(repository).updateMeasurement(measurement);
|
verify(repository).updateMeasurement(measurement);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void confirmedPackageCountPreservesTheRuleSnapshotAfterCustomerConfigChanges() {
|
||||||
|
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 2);
|
||||||
|
InboundMeasurementItemEntity item = item(40001L, 30001L, 1, 99001L);
|
||||||
|
item.setOuterLengthCm(new BigDecimal("34"));
|
||||||
|
item.setOuterWidthCm(new BigDecimal("24"));
|
||||||
|
item.setOuterHeightCm(new BigDecimal("24"));
|
||||||
|
item.setUnitVolumeCbm(new BigDecimal("0.020"));
|
||||||
|
item.setCalculationRuleVersion("CUSTOMER_DIMENSION_SMALL_V3");
|
||||||
|
when(repository.findItem(10001L, 40001L)).thenReturn(item);
|
||||||
|
when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement);
|
||||||
|
when(repository.updateItem(item)).thenReturn(true);
|
||||||
|
when(repository.listItems(10001L, 30001L)).thenReturn(List.of(item));
|
||||||
|
when(repository.updateMeasurement(measurement)).thenReturn(true);
|
||||||
|
|
||||||
|
service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 3);
|
||||||
|
|
||||||
|
assertThat(item.getTotalVolumeCbm()).isEqualByComparingTo("0.0600");
|
||||||
|
assertThat(item.getOuterLengthCm()).isEqualByComparingTo("34");
|
||||||
|
assertThat(item.getCalculationRuleVersion()).isEqualTo("CUSTOMER_DIMENSION_SMALL_V3");
|
||||||
|
verify(dimensionConfigs, never()).findAll(10001L);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void confirmedPackageCountCanBeCorrectedAfterSubmissionWithoutChangingOriginalPackageCount() {
|
void confirmedPackageCountCanBeCorrectedAfterSubmissionWithoutChangingOriginalPackageCount() {
|
||||||
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
|
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
|
||||||
@@ -158,13 +192,13 @@ class CartonMeasurementServiceTest {
|
|||||||
service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 5);
|
service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 5);
|
||||||
|
|
||||||
assertThat(inbound.getTotalPackageCount()).isEqualTo(5);
|
assertThat(inbound.getTotalPackageCount()).isEqualTo(5);
|
||||||
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.3000");
|
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.5250");
|
||||||
assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("50.100");
|
assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("87.675");
|
||||||
verify(repository).updateInbound(inbound);
|
verify(repository).updateInbound(inbound);
|
||||||
}
|
}
|
||||||
|
|
||||||
private CartonMeasurementCommand command() {
|
private CartonMeasurementCommand command() {
|
||||||
return new CartonMeasurementCommand("RK-001#5-1", new BigDecimal("60.100"),
|
return new CartonMeasurementCommand("RK-001#5-1", new BigDecimal("59.100"),
|
||||||
new BigDecimal("40.200"), new BigDecimal("30.300"), new BigDecimal("12.500"),
|
new BigDecimal("40.200"), new BigDecimal("30.300"), new BigDecimal("12.500"),
|
||||||
"MJ-01", LocalDateTime.parse("2026-08-07T10:30:00"),
|
"MJ-01", LocalDateTime.parse("2026-08-07T10:30:00"),
|
||||||
new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes()));
|
new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes()));
|
||||||
@@ -193,4 +227,15 @@ class CartonMeasurementServiceTest {
|
|||||||
item.setSourceImageFileId(imageId); item.setItemNo("B1"); item.setBoxSpecification("50x40x30cm");
|
item.setSourceImageFileId(imageId); item.setItemNo("B1"); item.setBoxSpecification("50x40x30cm");
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<CustomerDimensionConfigEntity> dimensionRules(long tenantId) {
|
||||||
|
return java.util.Arrays.stream(CustomerDimensionBoxType.values()).map(type -> {
|
||||||
|
CustomerDimensionConfigEntity rule = new CustomerDimensionConfigEntity();
|
||||||
|
rule.setTenantId(tenantId); rule.setBoxType(type); rule.setVersion(0);
|
||||||
|
rule.setInnerLengthCm(type.defaultLengthCm()); rule.setInnerWidthCm(type.defaultWidthCm());
|
||||||
|
rule.setInnerHeightCm(type.defaultHeightCm());
|
||||||
|
rule.setEdgeAdditionCm(new BigDecimal("2.000"));
|
||||||
|
return rule;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
|
import com.baiyue.firstleg.domain.CustomerDimensionBoxType;
|
||||||
|
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
|
||||||
|
import com.baiyue.firstleg.pricing.FbaWarehouseRateEstimator;
|
||||||
|
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
|
||||||
|
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/** Representative test-environment data flowing from dimension weighting into channel pricing. */
|
||||||
|
class CustomerDimensionPricingFlowTest {
|
||||||
|
private final CustomerDimensionConfigRepository configs = mock(CustomerDimensionConfigRepository.class);
|
||||||
|
private final CustomerDimensionWeightingService dimensions = new CustomerDimensionWeightingService(configs);
|
||||||
|
private final FbaWarehouseRateEstimator pricing = new FbaWarehouseRateEstimator();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void channelRateChoosesWeightOrVolumeAfterCustomerDimensionWeighting() {
|
||||||
|
when(configs.findAll(10001L)).thenReturn(defaultRules());
|
||||||
|
var small = dimensions.load(10001L).resolve(
|
||||||
|
new BigDecimal("10"), new BigDecimal("10"), new BigDecimal("10"));
|
||||||
|
BigDecimal twoCartonsVolume = small.unitVolumeCbm().multiply(new BigDecimal("2"));
|
||||||
|
BigDecimal actualWeight = new BigDecimal("24");
|
||||||
|
|
||||||
|
var kgEstimate = pricing.estimate(rate("KG", "8.700000"), actualWeight, twoCartonsVolume);
|
||||||
|
var cbmEstimate = pricing.estimate(rate("CBM", "1400.000000"), actualWeight, twoCartonsVolume);
|
||||||
|
|
||||||
|
assertThat(twoCartonsVolume).isEqualByComparingTo("0.040");
|
||||||
|
assertThat(kgEstimate.quantity()).isEqualByComparingTo("24.000000");
|
||||||
|
assertThat(kgEstimate.amount()).isEqualByComparingTo("208.80");
|
||||||
|
assertThat(cbmEstimate.quantity()).isEqualByComparingTo("0.066116");
|
||||||
|
assertThat(cbmEstimate.amount()).isEqualByComparingTo("92.56");
|
||||||
|
}
|
||||||
|
|
||||||
|
private RateRow rate(String unit, String price) {
|
||||||
|
RateRow rate = new RateRow();
|
||||||
|
rate.setChannelCode("EXX_TRUCK");
|
||||||
|
rate.setDestinationType("OVERSEAS");
|
||||||
|
rate.setDestinationWarehouseId(193000000000200003L);
|
||||||
|
rate.setDestinationName("洛杉矶仓");
|
||||||
|
rate.setChargeUnit(unit);
|
||||||
|
rate.setUnitPrice(new BigDecimal(price));
|
||||||
|
rate.setCurrency("CNY");
|
||||||
|
return rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CustomerDimensionConfigEntity> defaultRules() {
|
||||||
|
return Arrays.stream(CustomerDimensionBoxType.values()).map(type -> {
|
||||||
|
CustomerDimensionConfigEntity rule = new CustomerDimensionConfigEntity();
|
||||||
|
rule.setTenantId(10001L); rule.setBoxType(type); rule.setVersion(0);
|
||||||
|
rule.setInnerLengthCm(type.defaultLengthCm()); rule.setInnerWidthCm(type.defaultWidthCm());
|
||||||
|
rule.setInnerHeightCm(type.defaultHeightCm());
|
||||||
|
rule.setEdgeAdditionCm(new BigDecimal("2.000"));
|
||||||
|
return rule;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.firstleg.domain.CustomerDimensionBoxType;
|
||||||
|
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
|
||||||
|
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
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.when;
|
||||||
|
|
||||||
|
class CustomerDimensionWeightingServiceTest {
|
||||||
|
private final CustomerDimensionConfigRepository repository = mock(CustomerDimensionConfigRepository.class);
|
||||||
|
private final CustomerDimensionWeightingService service = new CustomerDimensionWeightingService(repository);
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
when(repository.findAll(10001L)).thenReturn(defaultRules(10001L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void measuredDimensionsUseSmallestContainingRuleAndAddEachEdgeTwice() {
|
||||||
|
var weighted = service.load(10001L).resolve(
|
||||||
|
new BigDecimal("10"), new BigDecimal("10"), new BigDecimal("10"));
|
||||||
|
|
||||||
|
assertThat(weighted.boxType()).isEqualTo(CustomerDimensionBoxType.SMALL);
|
||||||
|
assertThat(weighted.outerLengthCm()).isEqualByComparingTo("34.000");
|
||||||
|
assertThat(weighted.outerWidthCm()).isEqualByComparingTo("24.000");
|
||||||
|
assertThat(weighted.outerHeightCm()).isEqualByComparingTo("24.000");
|
||||||
|
assertThat(weighted.rawVolumeCbm()).isEqualByComparingTo("0.019584");
|
||||||
|
assertThat(weighted.unitVolumeCbm()).isEqualByComparingTo("0.020");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cartonRotationDoesNotChangeTheMatchedRule() {
|
||||||
|
var weighted = service.load(10001L).resolve(
|
||||||
|
new BigDecimal("20"), new BigDecimal("30"), new BigDecimal("20"));
|
||||||
|
|
||||||
|
assertThat(weighted.boxType()).isEqualTo(CustomerDimensionBoxType.SMALL);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void crossingOneSmallBoxBoundaryUsesTheNextContainingRule() {
|
||||||
|
var weighted = service.load(10001L).resolve(
|
||||||
|
new BigDecimal("30.001"), new BigDecimal("20"), new BigDecimal("20"));
|
||||||
|
|
||||||
|
assertThat(weighted.boxType()).isEqualTo(CustomerDimensionBoxType.MEDIUM);
|
||||||
|
assertThat(weighted.unitVolumeCbm()).isEqualByComparingTo("0.051");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dimensionsBeyondTheLargestRuleAreRejected() {
|
||||||
|
assertThatThrownBy(() -> service.load(10001L).resolve(
|
||||||
|
new BigDecimal("60.001"), new BigDecimal("50"), new BigDecimal("50")))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("超出客户最大箱规");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void incompleteCustomerRulesAreRejectedBeforeMeasurementCalculation() {
|
||||||
|
when(repository.findAll(10001L)).thenReturn(defaultRules(10001L).subList(0, 3));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.load(10001L))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("配置不完整");
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CustomerDimensionConfigEntity> defaultRules(long tenantId) {
|
||||||
|
return Arrays.stream(CustomerDimensionBoxType.values()).map(type -> {
|
||||||
|
CustomerDimensionConfigEntity rule = new CustomerDimensionConfigEntity();
|
||||||
|
rule.setTenantId(tenantId); rule.setBoxType(type); rule.setVersion(3);
|
||||||
|
rule.setInnerLengthCm(type.defaultLengthCm()); rule.setInnerWidthCm(type.defaultWidthCm());
|
||||||
|
rule.setInnerHeightCm(type.defaultHeightCm());
|
||||||
|
rule.setEdgeAdditionCm(new BigDecimal("2.000"));
|
||||||
|
return rule;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,8 +114,6 @@ class FbaPricingServiceTest {
|
|||||||
readyRepository();
|
readyRepository();
|
||||||
InboundPricingEntity inbound = inbound("100.000", "1.0000");
|
InboundPricingEntity inbound = inbound("100.000", "1.0000");
|
||||||
inbound.setProcessStatus("INBOUNDED");
|
inbound.setProcessStatus("INBOUNDED");
|
||||||
CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING");
|
|
||||||
CarrierFbaRateEntity rate = rate("KG", "2.000000", "0.00");
|
|
||||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||||
when(priceBookEstimator.estimate(any())).thenReturn(
|
when(priceBookEstimator.estimate(any())).thenReturn(
|
||||||
estimate("KG", "167.000", "2.000000", "334.00", "0.00"));
|
estimate("KG", "167.000", "2.000000", "334.00", "0.00"));
|
||||||
|
|||||||
+2
-3
@@ -3,7 +3,6 @@ package com.baiyue.firstleg.service;
|
|||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import com.baiyue.common.core.api.ApiResponse;
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
import com.baiyue.firstleg.client.OrderCargoClient;
|
import com.baiyue.firstleg.client.OrderCargoClient;
|
||||||
import com.baiyue.common.core.api.ApiResponse;
|
|
||||||
import com.baiyue.firstleg.client.MeasurementImageClient;
|
import com.baiyue.firstleg.client.MeasurementImageClient;
|
||||||
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
|
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
|
||||||
import com.baiyue.firstleg.dto.MeasurementBatchCommand;
|
import com.baiyue.firstleg.dto.MeasurementBatchCommand;
|
||||||
@@ -519,7 +518,7 @@ class InboundMeasurementServiceTest {
|
|||||||
saved.setVersion(0);
|
saved.setVersion(0);
|
||||||
saved.setStatus("DRAFT");
|
saved.setStatus("DRAFT");
|
||||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
||||||
when(orderCargoClient.detail(eq(""), eq(1L), eq(80L), eq(80001L))).thenReturn(
|
when(orderCargoClient.detail(eq("internal-token"), eq(1L), eq(80L), eq(80001L))).thenReturn(
|
||||||
ApiResponse.success(new OrderCargoClient.CargoBinding(80001L, 80L, 1, "Test cargo",
|
ApiResponse.success(new OrderCargoClient.CargoBinding(80001L, 80L, 1, "Test cargo",
|
||||||
2, new BigDecimal("6.500"), new BigDecimal("0.0480"))));
|
2, new BigDecimal("6.500"), new BigDecimal("0.0480"))));
|
||||||
when(batchService.saveBatch(any())).thenReturn(saved);
|
when(batchService.saveBatch(any())).thenReturn(saved);
|
||||||
@@ -534,7 +533,7 @@ class InboundMeasurementServiceTest {
|
|||||||
ArgumentCaptor<MeasurementBatchCommand> command = ArgumentCaptor.forClass(MeasurementBatchCommand.class);
|
ArgumentCaptor<MeasurementBatchCommand> command = ArgumentCaptor.forClass(MeasurementBatchCommand.class);
|
||||||
verify(batchService).saveBatch(command.capture());
|
verify(batchService).saveBatch(command.capture());
|
||||||
assertThat(command.getValue().items().get(0).inboundDetailId()).isEqualTo(80001L);
|
assertThat(command.getValue().items().get(0).inboundDetailId()).isEqualTo(80001L);
|
||||||
verify(orderCargoClient).detail("", 1L, 80L, 80001L);
|
verify(orderCargoClient).detail("internal-token", 1L, 80L, 80001L);
|
||||||
}
|
}
|
||||||
|
|
||||||
private InboundOrderEntity inbound(String status) {
|
private InboundOrderEntity inbound(String status) {
|
||||||
|
|||||||
+26
-5
@@ -2,10 +2,13 @@ package com.baiyue.firstleg.service;
|
|||||||
|
|
||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import com.baiyue.firstleg.dto.MeasurementBatchCommand;
|
import com.baiyue.firstleg.dto.MeasurementBatchCommand;
|
||||||
|
import com.baiyue.firstleg.domain.CustomerDimensionBoxType;
|
||||||
|
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
|
||||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||||
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||||
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
|
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
|
||||||
|
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
@@ -27,14 +30,16 @@ import static org.mockito.Mockito.verify;
|
|||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@DisplayName("测量批次共享领域服务")
|
@DisplayName("测量批次共享领域服务")
|
||||||
@SpringJUnitConfig(MeasurementBatchService.class)
|
@SpringJUnitConfig({MeasurementBatchService.class, CustomerDimensionWeightingService.class})
|
||||||
class MeasurementBatchServiceTest {
|
class MeasurementBatchServiceTest {
|
||||||
@MockBean private InboundMeasurementRepository repository;
|
@MockBean private InboundMeasurementRepository repository;
|
||||||
|
@MockBean private CustomerDimensionConfigRepository dimensionConfigs;
|
||||||
@Resource private MeasurementBatchService service;
|
@Resource private MeasurementBatchService service;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound());
|
when(repository.findInbound(1L, 10L)).thenReturn(inbound());
|
||||||
|
when(dimensionConfigs.findAll(1L)).thenReturn(dimensionRules(1L));
|
||||||
org.mockito.Mockito.doAnswer(invocation -> {
|
org.mockito.Mockito.doAnswer(invocation -> {
|
||||||
InboundMeasurementEntity entity = invocation.getArgument(0);
|
InboundMeasurementEntity entity = invocation.getArgument(0);
|
||||||
entity.setId(100L);
|
entity.setId(100L);
|
||||||
@@ -57,8 +62,13 @@ class MeasurementBatchServiceTest {
|
|||||||
ArgumentCaptor<List<InboundMeasurementItemEntity>> items = ArgumentCaptor.forClass(List.class);
|
ArgumentCaptor<List<InboundMeasurementItemEntity>> items = ArgumentCaptor.forClass(List.class);
|
||||||
verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
|
verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
|
||||||
org.mockito.ArgumentMatchers.eq(100L), items.capture());
|
org.mockito.ArgumentMatchers.eq(100L), items.capture());
|
||||||
assertThat(items.getValue().get(0).getUnitVolumeCbm()).isEqualByComparingTo("0.0240");
|
assertThat(items.getValue().get(0).getUnitVolumeCbm()).isEqualByComparingTo("0.0510");
|
||||||
assertThat(items.getValue().get(0).getTotalVolumeCbm()).isEqualByComparingTo("0.0480");
|
assertThat(items.getValue().get(0).getTotalVolumeCbm()).isEqualByComparingTo("0.1020");
|
||||||
|
assertThat(items.getValue().get(0).getOuterLengthCm()).isEqualByComparingTo("44.000");
|
||||||
|
assertThat(items.getValue().get(0).getOuterWidthCm()).isEqualByComparingTo("34.000");
|
||||||
|
assertThat(items.getValue().get(0).getOuterHeightCm()).isEqualByComparingTo("34.000");
|
||||||
|
assertThat(items.getValue().get(0).getCalculationRuleVersion())
|
||||||
|
.isEqualTo("CUSTOMER_DIMENSION_MEDIUM_V0");
|
||||||
assertThat(items.getValue().get(0).getGirthCm()).isEqualByComparingTo("140.000");
|
assertThat(items.getValue().get(0).getGirthCm()).isEqualByComparingTo("140.000");
|
||||||
assertThat(items.getValue().get(0).getUnitWeightKg()).isNull();
|
assertThat(items.getValue().get(0).getUnitWeightKg()).isNull();
|
||||||
verify(repository, never()).updateInbound(any());
|
verify(repository, never()).updateInbound(any());
|
||||||
@@ -74,8 +84,8 @@ class MeasurementBatchServiceTest {
|
|||||||
InboundMeasurementEntity saved = service.saveBatch(command);
|
InboundMeasurementEntity saved = service.saveBatch(command);
|
||||||
|
|
||||||
assertThat(saved.getStatus()).isEqualTo("DRAFT");
|
assertThat(saved.getStatus()).isEqualTo("DRAFT");
|
||||||
assertThat(saved.getTotalVolumeCbm()).isEqualByComparingTo("0.0480");
|
assertThat(saved.getTotalVolumeCbm()).isEqualByComparingTo("0.1020");
|
||||||
assertThat(saved.getVolumeWeightKg()).isEqualByComparingTo("8.016");
|
assertThat(saved.getVolumeWeightKg()).isEqualByComparingTo("17.034");
|
||||||
ArgumentCaptor<List<InboundMeasurementItemEntity>> items = ArgumentCaptor.forClass(List.class);
|
ArgumentCaptor<List<InboundMeasurementItemEntity>> items = ArgumentCaptor.forClass(List.class);
|
||||||
verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
|
verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
|
||||||
org.mockito.ArgumentMatchers.eq(100L), items.capture());
|
org.mockito.ArgumentMatchers.eq(100L), items.capture());
|
||||||
@@ -145,4 +155,15 @@ class MeasurementBatchServiceTest {
|
|||||||
inbound.setVersion(0);
|
inbound.setVersion(0);
|
||||||
return inbound;
|
return inbound;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<CustomerDimensionConfigEntity> dimensionRules(long tenantId) {
|
||||||
|
return java.util.Arrays.stream(CustomerDimensionBoxType.values()).map(type -> {
|
||||||
|
CustomerDimensionConfigEntity rule = new CustomerDimensionConfigEntity();
|
||||||
|
rule.setTenantId(tenantId); rule.setBoxType(type); rule.setVersion(0);
|
||||||
|
rule.setInnerLengthCm(type.defaultLengthCm()); rule.setInnerWidthCm(type.defaultWidthCm());
|
||||||
|
rule.setInnerHeightCm(type.defaultHeightCm());
|
||||||
|
rule.setEdgeAdditionCm(new BigDecimal("2.000"));
|
||||||
|
return rule;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,536 @@
|
|||||||
|
param(
|
||||||
|
[string]$GatewayBaseUrl = 'http://8.163.30.128:9000',
|
||||||
|
[string]$PlatformUsername = 'platform_admin',
|
||||||
|
[string]$MerchantUsername = 'merchant_admin',
|
||||||
|
[string]$PlatformPassword = 'ChangeMe123!',
|
||||||
|
[string]$MerchantPassword = 'ChangeMe123!',
|
||||||
|
[long]$TenantId = 10001,
|
||||||
|
[string]$ImageDirectory = 'C:\Users\BY-OY\Desktop\上传图片包\测量图片\测量图片',
|
||||||
|
[long]$ExistingInboundId = 0,
|
||||||
|
[long]$ExistingOrderId = 0,
|
||||||
|
[string]$ExistingInboundCode = '',
|
||||||
|
[int]$ExistingCartonCount = 0,
|
||||||
|
[switch]$OcrOnly,
|
||||||
|
[switch]$OcrProbeOnly,
|
||||||
|
[string]$OutputPath = ''
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$repoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||||
|
$stamp = Get-Date -Format 'yyyyMMddHHmmss'
|
||||||
|
$marker = "DIM-OCR-PRICE-$stamp"
|
||||||
|
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||||
|
$OutputPath = Join-Path $repoRoot "reports\尺寸OCR渠道计费测试环境验收-$stamp.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
$originWarehouseId = '193000000000200001'
|
||||||
|
$destinationWarehouseId = '193000000000200003'
|
||||||
|
$customerId = '191000000000000001'
|
||||||
|
$successfulImages = @(
|
||||||
|
'orig_030e8fd8-8ff3-4713-81f1-33b964887844.jpg',
|
||||||
|
'orig_192da04f-1cfb-415b-ae2d-4b4f63fda871.JPG',
|
||||||
|
'orig_f4525683-4ceb-4175-92a3-c3f66c8a9423.jpg'
|
||||||
|
)
|
||||||
|
$failedImage = 'orig_168c6cb6-ed65-49cc-b799-84c8855be97a.jpg'
|
||||||
|
$probeImages = @(
|
||||||
|
'orig_052f3c40-2dc8-432f-8f23-6961415bcac5.jpg',
|
||||||
|
'orig_07220408-d9a6-4950-9c30-e813d4e9d575.jpg',
|
||||||
|
'orig_0977314f-330e-4044-9f9c-0697c7af1c9e.jpg',
|
||||||
|
'orig_1064d1c3-0d84-43b4-b19d-62e6c4f2d65a.jpg',
|
||||||
|
'orig_15925407-b2f6-4c79-a5bc-e96ece7314ed.jpg',
|
||||||
|
'orig_172ff266-2e8a-49d1-8cc2-892ff23fbf3b.jpg',
|
||||||
|
'orig_1e091ecb-c3c1-46b3-95f4-727f81bfb2fb.jpg',
|
||||||
|
'orig_28149aa5-11bf-44d3-a3e6-6d457d5fd188.jpg',
|
||||||
|
'orig_31802a7b-294a-476a-ac5b-4c92a87df18e.jpg',
|
||||||
|
'orig_3c1f4815-a058-4cdd-9662-094d5a2e1ba7.jpg',
|
||||||
|
'orig_435124c6-d380-4f7a-aed8-75388a88685e.jpg',
|
||||||
|
'orig_490ac151-87b9-4bc6-8958-8c1aa9ff6cdd.jpg'
|
||||||
|
)
|
||||||
|
$scanDimensions = @(
|
||||||
|
@{ length = 10; width = 10; height = 10; expectedOuter = @(34, 24, 24) },
|
||||||
|
@{ length = 35; width = 25; height = 25; expectedOuter = @(44, 34, 34) },
|
||||||
|
@{ length = 45; width = 35; height = 35; expectedOuter = @(54, 44, 44) },
|
||||||
|
@{ length = 55; width = 45; height = 45; expectedOuter = @(64, 54, 54) }
|
||||||
|
)
|
||||||
|
|
||||||
|
$steps = [System.Collections.Generic.List[object]]::new()
|
||||||
|
$runs = [System.Collections.Generic.List[object]]::new()
|
||||||
|
$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "baiyue-dim-ocr-$stamp-$([guid]::NewGuid().ToString('N'))"
|
||||||
|
$null = New-Item -ItemType Directory -Path $tempRoot
|
||||||
|
|
||||||
|
function New-Key([string]$Action) {
|
||||||
|
return "$marker-$Action-$([guid]::NewGuid().ToString('N'))"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Api(
|
||||||
|
[string]$Method,
|
||||||
|
[string]$Path,
|
||||||
|
[string]$Token = '',
|
||||||
|
[object]$Body = $null,
|
||||||
|
[hashtable]$ExtraHeaders = @{}
|
||||||
|
) {
|
||||||
|
$headers = @{}
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($Token)) { $headers.Authorization = "Bearer $Token" }
|
||||||
|
foreach ($entry in $ExtraHeaders.GetEnumerator()) { $headers[$entry.Key] = $entry.Value }
|
||||||
|
$args = @{
|
||||||
|
Method = $Method
|
||||||
|
Uri = "$GatewayBaseUrl$Path"
|
||||||
|
Headers = $headers
|
||||||
|
TimeoutSec = 45
|
||||||
|
SkipHttpErrorCheck = $true
|
||||||
|
}
|
||||||
|
if ($null -ne $Body) {
|
||||||
|
$args.ContentType = 'application/json; charset=utf-8'
|
||||||
|
$jsonText = $Body | ConvertTo-Json -Depth 30 -Compress
|
||||||
|
$args.Body = [Text.Encoding]::UTF8.GetBytes($jsonText)
|
||||||
|
}
|
||||||
|
$response = Invoke-WebRequest @args
|
||||||
|
$json = $null
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($response.Content)) {
|
||||||
|
try { $json = $response.Content | ConvertFrom-Json } catch { }
|
||||||
|
}
|
||||||
|
return [pscustomobject]@{
|
||||||
|
http = [int]$response.StatusCode
|
||||||
|
body = $json
|
||||||
|
raw = if ($null -eq $json) { [string]$response.Content } else { $null }
|
||||||
|
requestId = if ($null -ne $json) { $json.requestId } else { $response.Headers['X-Request-Id'] }
|
||||||
|
traceId = if ($null -ne $json) { $json.traceId } else { $response.Headers['X-Trace-Id'] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Multipart(
|
||||||
|
[string]$Path,
|
||||||
|
[string]$Token,
|
||||||
|
[string]$FilePath,
|
||||||
|
[object]$Data = $null
|
||||||
|
) {
|
||||||
|
$responsePath = Join-Path $tempRoot "$([guid]::NewGuid().ToString('N')).json"
|
||||||
|
$curlArgs = @('-sS', '-X', 'POST', "$GatewayBaseUrl$Path", '-H', "Authorization: Bearer $Token")
|
||||||
|
if ($null -ne $Data) {
|
||||||
|
$dataPath = Join-Path $tempRoot "$([guid]::NewGuid().ToString('N')).json"
|
||||||
|
$Data | ConvertTo-Json -Depth 20 -Compress | Set-Content -LiteralPath $dataPath -Encoding utf8NoBOM
|
||||||
|
$curlArgs += @('-F', "data=@$dataPath;type=application/json")
|
||||||
|
}
|
||||||
|
$file = Get-Item -LiteralPath $FilePath
|
||||||
|
$curlArgs += @('-F', "$(if ($null -eq $Data) { 'file' } else { 'image' })=@$($file.FullName);type=image/jpeg")
|
||||||
|
$curlArgs += @('-o', $responsePath, '-w', '%{http_code}')
|
||||||
|
$httpText = & curl.exe @curlArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "multipart 请求传输失败:$Path" }
|
||||||
|
$content = Get-Content -LiteralPath $responsePath -Raw
|
||||||
|
$json = $null
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($content)) {
|
||||||
|
try { $json = $content | ConvertFrom-Json } catch { }
|
||||||
|
}
|
||||||
|
return [pscustomobject]@{
|
||||||
|
http = [int]$httpText
|
||||||
|
body = $json
|
||||||
|
raw = if ($null -eq $json) { $content } else { $null }
|
||||||
|
requestId = if ($null -ne $json) { $json.requestId } else { $null }
|
||||||
|
traceId = if ($null -ne $json) { $json.traceId } else { $null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Add-Step([string]$Run, [string]$Name, $Response, [bool]$Passed, [string]$Evidence = '') {
|
||||||
|
$step = [pscustomobject]@{
|
||||||
|
run = $Run
|
||||||
|
name = $Name
|
||||||
|
passed = $Passed
|
||||||
|
http = $Response.http
|
||||||
|
code = if ($null -ne $Response.body) { [string]$Response.body.code } else { $null }
|
||||||
|
message = if ($null -ne $Response.body) { [string]$Response.body.message } else { [string]$Response.raw }
|
||||||
|
requestId = $Response.requestId
|
||||||
|
traceId = $Response.traceId
|
||||||
|
evidence = $Evidence
|
||||||
|
}
|
||||||
|
$steps.Add($step)
|
||||||
|
Write-Host "[$(if ($Passed) { 'PASS' } else { 'FAIL' })][$Run] $Name $Evidence"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Require-Success([string]$Run, [string]$Name, $Response, [string]$Evidence = '') {
|
||||||
|
$passed = $Response.http -eq 200 -and [string]$Response.body.code -eq '200'
|
||||||
|
Add-Step $Run $Name $Response $passed $Evidence
|
||||||
|
if (-not $passed) {
|
||||||
|
throw "$Run / $Name failed: HTTP=$($Response.http), code=$($Response.body.code), message=$($Response.body.message)"
|
||||||
|
}
|
||||||
|
return $Response.body.data
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-Cargo([int]$Index) {
|
||||||
|
$dimension = $scanDimensions[$Index - 1]
|
||||||
|
return [ordered]@{
|
||||||
|
cargoName = "测试产品$Index"
|
||||||
|
cargoNameEn = "Dimension Test Product $Index"
|
||||||
|
hsCode = '8518210000'
|
||||||
|
customsHsCode = '8518210000'
|
||||||
|
packageType = 'CARTON'
|
||||||
|
packageCount = 1
|
||||||
|
grossWeightKg = 5
|
||||||
|
netWeightKg = 4.5
|
||||||
|
volumeCbm = [decimal]($dimension.length * $dimension.width * $dimension.height / 1000000)
|
||||||
|
lengthCm = $dimension.length
|
||||||
|
widthCm = $dimension.width
|
||||||
|
heightCm = $dimension.height
|
||||||
|
declaredValue = 100
|
||||||
|
unitDeclaredValue = 100
|
||||||
|
currency = 'USD'
|
||||||
|
dangerousGoods = $false
|
||||||
|
cargoRemark = "$marker tier-$Index"
|
||||||
|
skuNo = "SKU-$stamp-$Index"
|
||||||
|
upcNo = "UPC-$stamp-$Index"
|
||||||
|
unitsPerPackage = 1
|
||||||
|
totalQuantity = 1
|
||||||
|
material = '塑料 / Plastic'
|
||||||
|
usage = '测试 / Test'
|
||||||
|
model = "DIM-$Index"
|
||||||
|
brand = 'BAIYUE-E2E'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-OrderPayload([string]$PricingTaxMode) {
|
||||||
|
$cargoes = @()
|
||||||
|
1..4 | ForEach-Object { $cargoes += New-Cargo $_ }
|
||||||
|
return [ordered]@{
|
||||||
|
customerId = $customerId
|
||||||
|
customerName = '深圳示例贸易有限公司'
|
||||||
|
transportMode = 'SEA'
|
||||||
|
tradeTerm = 'FOB'
|
||||||
|
destinationCountryCode = 'US'
|
||||||
|
destinationWarehouseId = $destinationWarehouseId
|
||||||
|
destinationWarehouseName = '洛杉矶仓'
|
||||||
|
expectedShipDate = (Get-Date).AddDays(10).ToString('yyyy-MM-dd')
|
||||||
|
customerRemark = "$marker $PricingTaxMode"
|
||||||
|
sender = [ordered]@{
|
||||||
|
companyName = '深圳示例贸易有限公司'
|
||||||
|
contactName = '测试员'
|
||||||
|
phone = '13800138000'
|
||||||
|
email = 'e2e@example.com'
|
||||||
|
countryCode = 'CN'
|
||||||
|
province = '浙江省'
|
||||||
|
city = '义乌市'
|
||||||
|
district = '义乌市'
|
||||||
|
addressLine = '测试环境全链路地址'
|
||||||
|
postalCode = '322000'
|
||||||
|
}
|
||||||
|
receiver = $null
|
||||||
|
cargoes = $cargoes
|
||||||
|
pickupMethod = 'PICKUP'
|
||||||
|
receivingWarehouseId = $originWarehouseId
|
||||||
|
receivingWarehouseName = '义乌仓'
|
||||||
|
channelCode = 'EXX_TRUCK'
|
||||||
|
channelName = 'EXX_TRUCK'
|
||||||
|
carrierId = $null
|
||||||
|
carrierName = $null
|
||||||
|
taxMode = 'BUY_DECLARATION'
|
||||||
|
pricingTaxMode = $PricingTaxMode
|
||||||
|
containerMode = 'LCL'
|
||||||
|
customsInspection = $false
|
||||||
|
destinationType = 'OVERSEAS'
|
||||||
|
amazonWarehouseCode = $null
|
||||||
|
shipmentId = "SHIP-$stamp"
|
||||||
|
referenceId = "$marker-$PricingTaxMode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Create-ApprovedInbound([string]$RunName, [string]$MerchantToken, [string]$PlatformToken,
|
||||||
|
[string]$PricingTaxMode) {
|
||||||
|
$create = Invoke-Api POST '/api/merchant/v1/orders/first-leg' $MerchantToken (New-OrderPayload $PricingTaxMode) @{
|
||||||
|
'Idempotency-Key' = New-Key "create-$PricingTaxMode"
|
||||||
|
}
|
||||||
|
$orderId = [string](Require-Success $RunName '创建订单' $create)
|
||||||
|
$submit = Invoke-Api POST "/api/merchant/v1/orders/first-leg/$orderId/submit" $MerchantToken $null @{
|
||||||
|
'Idempotency-Key' = New-Key "submit-$PricingTaxMode"
|
||||||
|
}
|
||||||
|
$null = Require-Success $RunName '提交订单' $submit "orderId=$orderId"
|
||||||
|
$approve = Invoke-Api POST "/api/platform/v1/orders/first-leg/$orderId/approve?tenantId=$TenantId" $PlatformToken
|
||||||
|
$null = Require-Success $RunName '审核订单' $approve "orderId=$orderId"
|
||||||
|
|
||||||
|
$detail = $null
|
||||||
|
$detailResponse = $null
|
||||||
|
for ($attempt = 1; $attempt -le 20; $attempt++) {
|
||||||
|
$detailResponse = Invoke-Api GET "/api/platform/v1/orders/$orderId`?tenantId=$TenantId" $PlatformToken
|
||||||
|
if ($detailResponse.http -eq 200 -and [string]$detailResponse.body.code -eq '200' -and
|
||||||
|
$null -ne $detailResponse.body.data.summary.inboundId) {
|
||||||
|
$detail = $detailResponse.body.data
|
||||||
|
break
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
}
|
||||||
|
$ready = $null -ne $detail -and $null -ne $detail.summary.inboundId
|
||||||
|
Add-Step $RunName '生成入库单' $detailResponse $ready $(if ($ready) { "inboundId=$($detail.summary.inboundId)" } else { '60秒内未生成' })
|
||||||
|
if (-not $ready) { throw "$RunName 入库单未生成" }
|
||||||
|
return [pscustomobject]@{
|
||||||
|
orderId = $orderId
|
||||||
|
inboundId = [string]$detail.summary.inboundId
|
||||||
|
inboundCode = [string]$detail.summary.inboundCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Initialize-CartonLabels([string]$RunName, [string]$PlatformToken, [string]$InboundId) {
|
||||||
|
$response = Invoke-WebRequest -Method POST -Uri "$GatewayBaseUrl/api/platform/inbounds/$InboundId/carton-label-pdf?tenantId=$TenantId" `
|
||||||
|
-Headers @{ Authorization = "Bearer $PlatformToken" } -TimeoutSec 60 -SkipHttpErrorCheck
|
||||||
|
$contentType = [string]$response.Headers['Content-Type']
|
||||||
|
$passed = [int]$response.StatusCode -eq 200 -and $contentType.StartsWith('application/pdf')
|
||||||
|
$fakeResponse = [pscustomobject]@{
|
||||||
|
http = [int]$response.StatusCode
|
||||||
|
body = $null
|
||||||
|
raw = if ($passed) { $null } else { [string]$response.Content }
|
||||||
|
requestId = $response.Headers['X-Request-Id']
|
||||||
|
traceId = $response.Headers['X-Trace-Id']
|
||||||
|
}
|
||||||
|
Add-Step $RunName '生成并冻结箱码' $fakeResponse $passed "contentType=$contentType"
|
||||||
|
if (-not $passed) { throw "$RunName 箱码 PDF 生成失败" }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Scan-Cartons([string]$RunName, [string]$PlatformToken, [string]$InboundId,
|
||||||
|
[string]$InboundCode, [int]$TotalCartons = 4) {
|
||||||
|
$results = @()
|
||||||
|
for ($index = 1; $index -le 4; $index++) {
|
||||||
|
$dimension = $scanDimensions[$index - 1]
|
||||||
|
$imageName = if ($index -le 3) { $successfulImages[$index - 1] } else { $failedImage }
|
||||||
|
$scan = Invoke-Multipart "/api/platform/v1/first-leg/inbounds/$InboundId/measurement/carton-scans?tenantId=$TenantId" `
|
||||||
|
$PlatformToken (Join-Path $ImageDirectory $imageName) @{
|
||||||
|
boxCode = "$InboundCode#$TotalCartons-$index"
|
||||||
|
length = $dimension.length
|
||||||
|
width = $dimension.width
|
||||||
|
height = $dimension.height
|
||||||
|
weight = 5 + $index
|
||||||
|
machineNo = 'MOJIA-E2E-01'
|
||||||
|
measuredAt = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ss')
|
||||||
|
}
|
||||||
|
$saved = Require-Success $RunName "墨家箱测量-$index" $scan "boxCode=$InboundCode#$TotalCartons-$index"
|
||||||
|
$results += $saved
|
||||||
|
}
|
||||||
|
return $results
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-RepresentativeCartonCounts([string]$RunName, [string]$PlatformToken, [string]$InboundId,
|
||||||
|
[object[]]$Scans, [int]$TotalCartons) {
|
||||||
|
if ($TotalCartons -eq $Scans.Count) { return }
|
||||||
|
if ($TotalCartons % $Scans.Count -ne 0) {
|
||||||
|
throw "总箱数 $TotalCartons 不能平均分配给 $($Scans.Count) 条测量记录"
|
||||||
|
}
|
||||||
|
$representativeCount = [int]($TotalCartons / $Scans.Count)
|
||||||
|
$detailResponse = Invoke-Api GET "/api/platform/v1/first-leg/inbounds/$InboundId/measurement?tenantId=$TenantId" $PlatformToken
|
||||||
|
$detail = Require-Success $RunName '读取箱数确认前测量版本' $detailResponse
|
||||||
|
$version = [int]$detail.version
|
||||||
|
foreach ($scan in $Scans) {
|
||||||
|
$update = Invoke-Api PUT "/api/platform/v1/first-leg/inbounds/$InboundId/measurement/items/$($scan.itemId)/confirmed-package-count?tenantId=$TenantId" `
|
||||||
|
$PlatformToken @{ measurementVersion = $version; confirmedPackageCount = $representativeCount }
|
||||||
|
$updated = Require-Success $RunName "确认代表箱数-$($scan.boxCode)" $update "confirmedPackageCount=$representativeCount"
|
||||||
|
$version = [int]$updated.measurementVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Upload-And-ConfirmWeight([string]$RunName, [string]$PlatformToken, [string]$InboundId,
|
||||||
|
[string]$ImageName, [object]$ManualWeight) {
|
||||||
|
$upload = Invoke-Multipart "/api/platform/v1/files/measurements/$TenantId/$InboundId" $PlatformToken `
|
||||||
|
(Join-Path $ImageDirectory $ImageName)
|
||||||
|
$uploaded = Require-Success $RunName "OCR-$ImageName" $upload
|
||||||
|
$ocrEvidence = "status=$($uploaded.recognitionStatus), weight=$($uploaded.weightValue), confidence=$($uploaded.confidence), error=$($uploaded.error)"
|
||||||
|
Add-Step $RunName "OCR结果-$ImageName" $upload ($uploaded.recognitionStatus -in @('RECOGNIZED', 'FAILED')) $ocrEvidence
|
||||||
|
|
||||||
|
if ($uploaded.recognitionStatus -eq 'FAILED') {
|
||||||
|
$retry = Invoke-Api POST "/api/platform/v1/files/measurements/$TenantId/$InboundId/$($uploaded.fileId)/recognize" $PlatformToken
|
||||||
|
$retried = Require-Success $RunName "OCR重试-$ImageName" $retry
|
||||||
|
Add-Step $RunName "OCR重试结果-$ImageName" $retry ($retried.recognitionStatus -in @('RECOGNIZED', 'FAILED')) `
|
||||||
|
"status=$($retried.recognitionStatus), error=$($retried.error)"
|
||||||
|
$uploaded = $retried
|
||||||
|
}
|
||||||
|
|
||||||
|
$create = Invoke-Api POST "/api/platform/v1/first-leg/inbounds/$InboundId/weight-results?tenantId=$TenantId" $PlatformToken @{
|
||||||
|
fileId = [string]$uploaded.fileId
|
||||||
|
ocrProvider = 'BAIDU_OCR_TEST_ENV'
|
||||||
|
ocrRawText = if ($uploaded.ocrRawText) { [string]$uploaded.ocrRawText } else { [string]$uploaded.error }
|
||||||
|
ocrWeightValue = $uploaded.weightValue
|
||||||
|
ocrWeightUnit = $uploaded.weightUnit
|
||||||
|
confidence = $uploaded.confidence
|
||||||
|
}
|
||||||
|
$weight = Require-Success $RunName "登记重量-$ImageName" $create
|
||||||
|
$confirmBody = @{ version = [int]$weight.version }
|
||||||
|
if ($null -ne $ManualWeight) {
|
||||||
|
$confirmBody.adjustedWeightValue = [decimal]$ManualWeight
|
||||||
|
$confirmBody.adjustedWeightUnit = 'KG'
|
||||||
|
$confirmBody.adjustmentReason = "$marker OCR失败人工读数"
|
||||||
|
}
|
||||||
|
$confirm = Invoke-Api POST "/api/platform/v1/first-leg/inbounds/$InboundId/weight-results/$($weight.id)/confirm?tenantId=$TenantId" `
|
||||||
|
$PlatformToken $confirmBody
|
||||||
|
return Require-Success $RunName "确认重量-$ImageName" $confirm
|
||||||
|
}
|
||||||
|
|
||||||
|
function Probe-Ocr([string]$PlatformToken, [string]$InboundId, [string]$ImageName) {
|
||||||
|
$upload = Invoke-Multipart "/api/platform/v1/files/measurements/$TenantId/$InboundId" $PlatformToken `
|
||||||
|
(Join-Path $ImageDirectory $ImageName)
|
||||||
|
$uploaded = Require-Success 'OCR-PROBE' "OCR-$ImageName" $upload
|
||||||
|
$evidence = "status=$($uploaded.recognitionStatus), weight=$($uploaded.weightValue), confidence=$($uploaded.confidence), raw=$($uploaded.ocrRawText), error=$($uploaded.error)"
|
||||||
|
Add-Step 'OCR-PROBE' "OCR结果-$ImageName" $upload ($uploaded.recognitionStatus -in @('RECOGNIZED', 'FAILED')) $evidence
|
||||||
|
return $uploaded
|
||||||
|
}
|
||||||
|
|
||||||
|
function Complete-MeasurementAndPrice([string]$RunName, [string]$PlatformToken, [string]$MerchantToken,
|
||||||
|
$Reference, [string[]]$WeightImages, [object]$ManualWeight,
|
||||||
|
[bool]$AllowPricingFailure = $false) {
|
||||||
|
foreach ($image in $WeightImages) {
|
||||||
|
$manual = if ($image -eq $failedImage) { $ManualWeight } else { $null }
|
||||||
|
$null = Upload-And-ConfirmWeight $RunName $PlatformToken $Reference.inboundId $image $manual
|
||||||
|
}
|
||||||
|
|
||||||
|
$measurementResponse = Invoke-Api GET "/api/platform/v1/first-leg/inbounds/$($Reference.inboundId)/measurement?tenantId=$TenantId" $PlatformToken
|
||||||
|
$measurement = Require-Success $RunName '读取测量汇总' $measurementResponse
|
||||||
|
$outer = @($measurement.items | Sort-Object boxCode | ForEach-Object {
|
||||||
|
@([decimal]$_.outerLengthCm, [decimal]$_.outerWidthCm, [decimal]$_.outerHeightCm)
|
||||||
|
})
|
||||||
|
$expectedOuter = @($scanDimensions | ForEach-Object { $_.expectedOuter })
|
||||||
|
$weighted = ($outer | ConvertTo-Json -Compress) -eq ($expectedOuter | ConvertTo-Json -Compress)
|
||||||
|
Add-Step $RunName '验证四档尺寸加权' $measurementResponse $weighted `
|
||||||
|
"actualOuter=$($outer | ConvertTo-Json -Compress), expectedOuter=$($expectedOuter | ConvertTo-Json -Compress), actualVolumeCbm=$($measurement.actualVolumeCbm)"
|
||||||
|
|
||||||
|
$submit = Invoke-Api POST "/api/platform/v1/first-leg/inbounds/$($Reference.inboundId)/measurement/submit?tenantId=$TenantId" `
|
||||||
|
$PlatformToken @{ measurementId = [string]$measurement.measurementId; version = [int]$measurement.version }
|
||||||
|
$submitted = Require-Success $RunName '提交测量审核' $submit
|
||||||
|
$inboundResponse = Invoke-Api GET "/api/platform/inbounds/$($Reference.inboundId)?tenantId=$TenantId" $PlatformToken
|
||||||
|
$inbound = Require-Success $RunName '读取待审核入库单' $inboundResponse
|
||||||
|
$approve = Invoke-Api POST "/api/platform/v1/inbounds/$($Reference.inboundId)/measurements/approve?tenantId=$TenantId" `
|
||||||
|
$PlatformToken @{
|
||||||
|
measurementId = [string]$measurement.measurementId
|
||||||
|
expectedVersion = [int]$inbound.summary.version
|
||||||
|
reason = $null
|
||||||
|
} @{ 'Idempotency-Key' = New-Key "measurement-approve-$RunName" }
|
||||||
|
$null = Require-Success $RunName '审核测量' $approve
|
||||||
|
|
||||||
|
$feeResponse = Invoke-Api POST "/api/platform/v1/inbounds/$($Reference.inboundId)/fees/calculate?tenantId=$TenantId" `
|
||||||
|
$PlatformToken @{} @{ 'Idempotency-Key' = New-Key "fee-$RunName" }
|
||||||
|
$feePassed = $feeResponse.http -eq 200 -and [string]$feeResponse.body.code -eq '200'
|
||||||
|
Add-Step $RunName '渠道计费' $feeResponse $feePassed $(if ($feePassed) {
|
||||||
|
"chargeUnit=$($feeResponse.body.data.chargeUnit), quantity=$($feeResponse.body.data.chargeableQuantity), total=$($feeResponse.body.data.totalAmount)"
|
||||||
|
} else {
|
||||||
|
"code=$($feeResponse.body.code), message=$($feeResponse.body.message)"
|
||||||
|
})
|
||||||
|
if (-not $feePassed -and -not $AllowPricingFailure) {
|
||||||
|
throw "$RunName / 渠道计费 failed: code=$($feeResponse.body.code), message=$($feeResponse.body.message)"
|
||||||
|
}
|
||||||
|
$fee = if ($feePassed) { $feeResponse.body.data } else { $null }
|
||||||
|
$billing = $null
|
||||||
|
if ($feePassed) {
|
||||||
|
$confirmResponse = Invoke-Api POST "/api/platform/v1/inbounds/$($Reference.inboundId)/fees/confirm?tenantId=$TenantId" `
|
||||||
|
$PlatformToken $null @{ 'Idempotency-Key' = New-Key "fee-confirm-$RunName" }
|
||||||
|
$confirmedFee = Require-Success $RunName '确认费用' $confirmResponse
|
||||||
|
if ([string]$confirmedFee.feeStatus -ne 'CONFIRMED') {
|
||||||
|
throw "$RunName / 确认费用状态异常: feeStatus=$($confirmedFee.feeStatus)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$sendResponse = Invoke-Api POST "/api/platform/v1/inbounds/$($Reference.inboundId)/fees/send?tenantId=$TenantId" `
|
||||||
|
$PlatformToken $null @{ 'Idempotency-Key' = New-Key "fee-send-$RunName" }
|
||||||
|
$fee = Require-Success $RunName '发布费用' $sendResponse
|
||||||
|
if ([string]$fee.feeStatus -ne 'SENT') {
|
||||||
|
throw "$RunName / 发布费用状态异常: feeStatus=$($fee.feeStatus)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$billingResponse = Invoke-Api GET "/api/merchant/v1/first-leg/orders/$($Reference.orderId)/inbound-billing" $MerchantToken
|
||||||
|
$billing = Require-Success $RunName '客户费用快照' $billingResponse
|
||||||
|
}
|
||||||
|
return [pscustomobject]@{
|
||||||
|
orderId = $Reference.orderId
|
||||||
|
inboundId = $Reference.inboundId
|
||||||
|
inboundCode = $Reference.inboundCode
|
||||||
|
measurement = $measurement
|
||||||
|
submitted = $submitted
|
||||||
|
fee = $fee
|
||||||
|
billing = $billing
|
||||||
|
dimensionWeightingPassed = $weighted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$fatal = $null
|
||||||
|
try {
|
||||||
|
$requiredImages = if ($OcrProbeOnly) { $probeImages } else { @($successfulImages + $failedImage) }
|
||||||
|
foreach ($imageName in $requiredImages) {
|
||||||
|
$imagePath = Join-Path $ImageDirectory $imageName
|
||||||
|
if (-not (Test-Path -LiteralPath $imagePath)) { throw "找不到测试图片:$imagePath" }
|
||||||
|
}
|
||||||
|
|
||||||
|
$platformLogin = Invoke-Api POST '/api/auth/login' '' @{ username = $PlatformUsername; password = $PlatformPassword }
|
||||||
|
$platform = Require-Success 'GLOBAL' '平台登录' $platformLogin
|
||||||
|
$merchantLogin = Invoke-Api POST '/api/auth/login' '' @{ username = $MerchantUsername; password = $MerchantPassword }
|
||||||
|
$merchant = Require-Success 'GLOBAL' '客户登录' $merchantLogin
|
||||||
|
|
||||||
|
if ($OcrProbeOnly) {
|
||||||
|
if ($ExistingInboundId -le 0) { throw 'OCR probe 模式必须提供 ExistingInboundId' }
|
||||||
|
$ocrResults = @()
|
||||||
|
foreach ($image in $probeImages) {
|
||||||
|
$ocrResults += Probe-Ocr $platform.accessToken ([string]$ExistingInboundId) $image
|
||||||
|
}
|
||||||
|
$runs.Add([pscustomobject]@{
|
||||||
|
inboundId = [string]$ExistingInboundId
|
||||||
|
ocrResults = $ocrResults
|
||||||
|
})
|
||||||
|
} elseif ($OcrOnly) {
|
||||||
|
if ($ExistingInboundId -le 0) { throw 'OCR-only 模式必须提供 ExistingInboundId' }
|
||||||
|
$weightResults = @()
|
||||||
|
foreach ($image in @($successfulImages + $failedImage)) {
|
||||||
|
$manual = if ($image -eq $failedImage) { [decimal]128 } else { $null }
|
||||||
|
$weightResults += Upload-And-ConfirmWeight 'OCR' $platform.accessToken ([string]$ExistingInboundId) $image $manual
|
||||||
|
}
|
||||||
|
$measurementResponse = Invoke-Api GET "/api/platform/v1/first-leg/inbounds/$ExistingInboundId/measurement?tenantId=$TenantId" $platform.accessToken
|
||||||
|
$measurement = Require-Success 'OCR' '读取确认后重量汇总' $measurementResponse
|
||||||
|
$runs.Add([pscustomobject]@{
|
||||||
|
inboundId = [string]$ExistingInboundId
|
||||||
|
weightResults = $weightResults
|
||||||
|
actualGrossWeightKg = $measurement.actualGrossWeightKg
|
||||||
|
measurement = $measurement
|
||||||
|
})
|
||||||
|
} elseif ($ExistingInboundId -gt 0) {
|
||||||
|
if ($ExistingOrderId -le 0 -or [string]::IsNullOrWhiteSpace($ExistingInboundCode) -or $ExistingCartonCount -lt 4) {
|
||||||
|
throw '恢复模式必须同时提供 ExistingOrderId、ExistingInboundCode 和至少 4 箱的 ExistingCartonCount'
|
||||||
|
}
|
||||||
|
$reference = [pscustomobject]@{
|
||||||
|
orderId = [string]$ExistingOrderId
|
||||||
|
inboundId = [string]$ExistingInboundId
|
||||||
|
inboundCode = $ExistingInboundCode
|
||||||
|
}
|
||||||
|
Initialize-CartonLabels 'EXISTING' $platform.accessToken $reference.inboundId
|
||||||
|
$scans = @(Scan-Cartons 'EXISTING' $platform.accessToken $reference.inboundId `
|
||||||
|
$reference.inboundCode $ExistingCartonCount)
|
||||||
|
Set-RepresentativeCartonCounts 'EXISTING' $platform.accessToken $reference.inboundId $scans $ExistingCartonCount
|
||||||
|
$existingRun = Complete-MeasurementAndPrice 'EXISTING' $platform.accessToken $merchant.accessToken $reference `
|
||||||
|
@($successfulImages + $failedImage) ([decimal]128) $true
|
||||||
|
$runs.Add($existingRun)
|
||||||
|
} else {
|
||||||
|
$kgReference = Create-ApprovedInbound 'KG' $merchant.accessToken $platform.accessToken 'TAX_INCLUDED'
|
||||||
|
Initialize-CartonLabels 'KG' $platform.accessToken $kgReference.inboundId
|
||||||
|
$null = Scan-Cartons 'KG' $platform.accessToken $kgReference.inboundId $kgReference.inboundCode
|
||||||
|
$kgRun = Complete-MeasurementAndPrice 'KG' $platform.accessToken $merchant.accessToken $kgReference `
|
||||||
|
$successfulImages $null
|
||||||
|
$runs.Add($kgRun)
|
||||||
|
|
||||||
|
$cbmReference = Create-ApprovedInbound 'CBM' $merchant.accessToken $platform.accessToken 'TAX_EXCLUDED'
|
||||||
|
Initialize-CartonLabels 'CBM' $platform.accessToken $cbmReference.inboundId
|
||||||
|
$null = Scan-Cartons 'CBM' $platform.accessToken $cbmReference.inboundId $cbmReference.inboundCode
|
||||||
|
$cbmRun = Complete-MeasurementAndPrice 'CBM' $platform.accessToken $merchant.accessToken $cbmReference `
|
||||||
|
@($failedImage) ([decimal]128)
|
||||||
|
$runs.Add($cbmRun)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$fatal = $_.Exception.Message
|
||||||
|
Write-Host "[FATAL] $fatal"
|
||||||
|
} finally {
|
||||||
|
$report = [ordered]@{
|
||||||
|
marker = $marker
|
||||||
|
gateway = $GatewayBaseUrl
|
||||||
|
tenantId = [string]$TenantId
|
||||||
|
generatedAt = (Get-Date).ToString('o')
|
||||||
|
success = $null -eq $fatal -and @($steps | Where-Object { -not $_.passed }).Count -eq 0
|
||||||
|
fatal = $fatal
|
||||||
|
runs = $runs
|
||||||
|
steps = $steps
|
||||||
|
}
|
||||||
|
$reportDirectory = Split-Path -Parent $OutputPath
|
||||||
|
if (-not (Test-Path -LiteralPath $reportDirectory)) { $null = New-Item -ItemType Directory -Path $reportDirectory }
|
||||||
|
$report | ConvertTo-Json -Depth 40 | Set-Content -LiteralPath $OutputPath -Encoding utf8NoBOM
|
||||||
|
if ((Resolve-Path -LiteralPath $tempRoot).Path.StartsWith([System.IO.Path]::GetTempPath(), [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
Remove-Item -LiteralPath $tempRoot -Recurse -Force
|
||||||
|
}
|
||||||
|
Write-Host "REPORT=$OutputPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -ne $fatal) { exit 1 }
|
||||||
|
if (@($steps | Where-Object { -not $_.passed }).Count -gt 0) { exit 2 }
|
||||||
Reference in New Issue
Block a user