diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 351c358a2..c3bae2f6a 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -6167,7 +6167,18 @@ SELECT UUID_SHORT(), tenant.tenant_id, defaults.box_type, ) defaults 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 $$ CREATE PROCEDURE phase3_inbound_add_column( IN p_table_name VARCHAR(64), @@ -6259,17 +6270,6 @@ BEGIN ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_order; END IF; 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 WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index 33e3cb2a8..ba8af670b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -243,13 +243,16 @@ public class FbaPricingRepository { .set(InboundFeeEntity::getSentAt, now)); } - public int markInboundWaitingPallet(long tenantId, long inboundId) { - return inbounds.update(null, new LambdaUpdateWrapper() + public int markInboundFeeSent(long tenantId, long inboundId, String processStatus) { + LambdaUpdateWrapper update = new LambdaUpdateWrapper() .eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId) - .eq(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING") + .eq(InboundPricingEntity::getProcessStatus, processStatus) .eq(InboundPricingEntity::getFeeStatus, "CONFIRMED") - .set(InboundPricingEntity::getProcessStatus, "WAIT_PALLET") - .set(InboundPricingEntity::getFeeStatus, "SENT")); + .set(InboundPricingEntity::getFeeStatus, "SENT"); + if ("FEE_CONFIRMING".equals(processStatus)) { + update.set(InboundPricingEntity::getProcessStatus, "WAIT_PALLET"); + } + return inbounds.update(null, update); } public InboundPricingIdempotencyEntity findIdempotency(long tenantId, long operatorId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java index 3767ad761..85adab831 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java @@ -35,15 +35,14 @@ public class CartonMeasurementService { private static final Set DEVICE_WRITABLE_STATUSES = Set.of("WAIT_MEASURE", "MEASURE_REJECTED"); private static final Set MANUAL_COUNT_EDITABLE_STATUSES = Set.of( "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 String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH"; private static final long MAX_IMAGE_BYTES = 5L * 1024 * 1024; private final InboundMeasurementRepository repository; private final ObjectProvider resolverProvider; private final MeasurementImageClient imageClient; private final TransactionTemplate transactionTemplate; + private final CustomerDimensionWeightingService dimensionWeighting; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -51,11 +50,13 @@ public class CartonMeasurementService { public CartonMeasurementService(InboundMeasurementRepository repository, ObjectProvider resolverProvider, MeasurementImageClient imageClient, - TransactionTemplate transactionTemplate) { + TransactionTemplate transactionTemplate, + CustomerDimensionWeightingService dimensionWeighting) { this.repository = repository; this.resolverProvider = resolverProvider; this.imageClient = imageClient; this.transactionTemplate = transactionTemplate; + this.dimensionWeighting = dimensionWeighting; } public CartonMeasurementResult upsert(long tenantId, long operatorId, CartonMeasurementCommand command) { @@ -118,7 +119,7 @@ public class CartonMeasurementService { } requireVersion(expectedMeasurementVersion, measurement.getVersion()); item.setPackageCount(confirmedPackageCount); - applyCalculatedValues(item); + applyCalculatedValuesFromSnapshot(item); item.setUpdatedAt(LocalDateTime.now()); if (!repository.updateItem(item)) { throw concurrentModification(); @@ -187,7 +188,7 @@ public class CartonMeasurementService { item.setMachineNo(command.machineNo().trim()); item.setDeviceMeasuredAt(command.measuredAt()); item.setUpdatedAt(LocalDateTime.now()); - applyCalculatedValues(item); + applyCalculatedValues(item, dimensionWeighting.load(tenantId)); if (created) { repository.insertItem(item); @@ -223,13 +224,15 @@ public class CartonMeasurementService { return measurement; } - private void applyCalculatedValues(InboundMeasurementItemEntity item) { - BigDecimal unitRaw = item.getLengthCm().multiply(item.getWidthCm()).multiply(item.getHeightCm()) - .divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP); - BigDecimal unitVolume = unitRaw.setScale(4, RoundingMode.CEILING); + private void applyCalculatedValues(InboundMeasurementItemEntity item, + CustomerDimensionWeightingService.RuleSet dimensionRules) { + CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolve( + item.getLengthCm(), item.getWidthCm(), item.getHeightCm()); + BigDecimal unitRaw = weighted.rawVolumeCbm(); + BigDecimal unitVolume = weighted.unitVolumeCbm(); BigDecimal count = BigDecimal.valueOf(item.getPackageCount()); 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()) .multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING)); item.setUnitWeightKg(item.getUnitWeightKg() == null ? null @@ -241,7 +244,31 @@ public class CartonMeasurementService { : item.getUnitWeightKg().multiply(count).setScale(3, RoundingMode.CEILING)); item.setTotalVolumeCbm(totalVolume); 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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java index 93a9b00c1..f91de8523 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java @@ -22,7 +22,7 @@ import java.util.EnumMap; import java.util.List; import java.util.Map; -/** 管理每个客户固定四种箱规的尺寸配置,不参与当前订单或计费计算。 */ +/** 管理每个客户固定四种箱规;规则供实测尺寸归档和计费体积加权使用,不包含价格。 */ @Service public class CustomerDimensionConfigService { public static final BigDecimal DEFAULT_EDGE_ADDITION_CM = new BigDecimal("2.000"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java new file mode 100644 index 000000000..499efe69b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java @@ -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 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 rules; + + private RuleSet(List 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 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 measured) { + List 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 sortedDimensions(BigDecimal length, BigDecimal width, BigDecimal height) { + if (!positive(length) || !positive(width) || !positive(height)) { + throw new BizException(ResultCode.VALIDATION_FAILED, "长、宽、高必须大于0"); + } + List dimensions = new ArrayList<>(List.of(length, width, height)); + dimensions.sort(Comparator.reverseOrder()); + return dimensions; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index ab85557bf..f439ead5e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -113,14 +113,16 @@ public class FbaPricingService { String hash = hash("SEND", inboundId, current.getId().toString()); InboundPricingIdempotencyEntity token = beginIdempotency( tenantId, operatorId, inboundId, "FEE_SEND", idempotencyKey, hash); + String nextProcessStatus = "INBOUNDED".equals(inbound.getProcessStatus()) + ? "INBOUNDED" : "WAIT_PALLET"; if ("COMPLETED".equals(token.getStatus())) { feeDetails.publishFreight(tenantId, inboundId, token.getResultResourceId(), operatorId, null, LocalDateTime.now(clock)); - return response(tenantId, token.getResultResourceId(), "WAIT_PALLET"); + return response(tenantId, token.getResultResourceId(), nextProcessStatus); } LocalDateTime now = LocalDateTime.now(clock); if (repository.sendFee(tenantId, current.getId(), operatorId, now) != 1 - || repository.markInboundWaitingPallet(tenantId, inboundId) != 1) { + || repository.markInboundFeeSent(tenantId, inboundId, inbound.getProcessStatus()) != 1) { throw conflict("费用未确认或入库单状态已变化,不能发送"); } feeDetails.publishFreight(tenantId, inboundId, current.getId(), operatorId, null, now); @@ -135,7 +137,7 @@ public class FbaPricingService { event.setCreatedAt(now); repository.insertOutbox(event); complete(token, current.getId(), now); - return response(tenantId, current.getId(), "WAIT_PALLET"); + return response(tenantId, current.getId(), nextProcessStatus); } public FeeSnapshotResponse latest(long tenantId, long inboundId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java index 274c3760a..f51611c52 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -35,10 +35,10 @@ public class MeasurementBatchService { /** 已接收第三方测量数据,待平台提交审核。 */ static final String STATUS_RECEIVED = "RECEIVED"; static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167"); - static final String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH"; - @Resource private InboundMeasurementRepository repository; + @Resource + private CustomerDimensionWeightingService dimensionWeighting; @Transactional public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) { @@ -47,7 +47,8 @@ public class MeasurementBatchService { throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); } validateSource(command); - List calculatedItems = calculateItems(command); + List calculatedItems = calculateItems( + command, dimensionWeighting.load(command.tenantId())); InboundMeasurementEntity measurement; if (command.measurementId() == null) { @@ -126,18 +127,20 @@ public class MeasurementBatchService { measurement.setActualGoodsName(command.actualGoodsName()); } - private List calculateItems(MeasurementBatchCommand command) { + private List calculateItems( + MeasurementBatchCommand command, CustomerDimensionWeightingService.RuleSet dimensionRules) { Set itemNumbers = new HashSet<>(); return command.items().stream().map(source -> { if (!itemNumbers.add(source.itemNo())) { throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细号不能重复"); } - BigDecimal unitVolumeRaw = source.lengthCm().multiply(source.widthCm()) - .multiply(source.heightCm()).divide(new BigDecimal("1000000"), 6, RoundingMode.HALF_UP); - BigDecimal unitVolume = unitVolumeRaw.setScale(4, RoundingMode.CEILING); + CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolve( + source.lengthCm(), source.widthCm(), source.heightCm()); + BigDecimal unitVolumeRaw = weighted.rawVolumeCbm(); + BigDecimal unitVolume = weighted.unitVolumeCbm(); BigDecimal packageCount = BigDecimal.valueOf(source.packageCount()); 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(); item.setInboundDetailId(source.inboundDetailId()); @@ -158,12 +161,12 @@ public class MeasurementBatchService { : source.unitWeightKg().multiply(packageCount).setScale(3, RoundingMode.CEILING)); item.setTotalVolumeCbm(totalVolume); 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.setOverLimitWarning(source.overLimitWarning()); - item.setOuterLengthCm(source.outerLengthCm()); - item.setOuterWidthCm(source.outerWidthCm()); - item.setOuterHeightCm(source.outerHeightCm()); + item.setOuterLengthCm(weighted.outerLengthCm()); + item.setOuterWidthCm(weighted.outerWidthCm()); + item.setOuterHeightCm(weighted.outerHeightCm()); item.setRemark(source.remark()); item.setCreatedAt(LocalDateTime.now()); return item; diff --git a/firstleg/src/main/resources/db/customer-dimension-config.sql b/firstleg/src/main/resources/db/customer-dimension-config.sql index e41481190..84052664a 100644 --- a/firstleg/src/main/resources/db/customer-dimension-config.sql +++ b/firstleg/src/main/resources/db/customer-dimension-config.sql @@ -1,7 +1,7 @@ SET NAMES utf8mb4; USE baiyue_firstleg; --- 每个客户固定四条箱规尺寸配置;当前只维护配置事实,不接入订单或计费。 +-- 每个客户固定四条箱规尺寸配置;用于实测尺寸归档和计费体积加权,不包含价格。 CREATE TABLE IF NOT EXISTS fl_customer_dimension_config ( id BIGINT UNSIGNED NOT NULL COMMENT '配置ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index 2b91746e8..7a492901a 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -130,7 +130,7 @@ paths: /api/platform/v1/inbounds/{inboundId}/fees/calculate: post: 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'}] requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/CalculateRequest'}}}} responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 3b57656d5..d7a4e0319 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -32,7 +32,7 @@ paths: /api/platform/v1/first-leg/customer-dimension-config: get: summary: 查询客户固定四箱规尺寸配置 - description: 需要 firstleg:dimension-config:view 权限;按 SMALL、MEDIUM、LARGE、EXTRA_LARGE 固定顺序返回,当前不参与订单或计费。 + description: 需要 firstleg:dimension-config:view 权限;按 SMALL、MEDIUM、LARGE、EXTRA_LARGE 固定顺序返回;规则用于实测尺寸归档和计费体积加权,不包含价格。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} responses: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java index 3be06be82..69ac70404 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java @@ -129,7 +129,7 @@ class CustomerDimensionConfigSqlContractTest { .contains("现有企业客户未完整持有固定四箱规尺寸配置"); 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) .doesNotContain("edge_addition_cm = VALUES") .doesNotContain("inner_length_cm = VALUES") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java index 454cfe888..be9fd8b35 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java @@ -1,6 +1,7 @@ package com.baiyue.firstleg.repository; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.AbstractWrapper; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.MybatisConfiguration; @@ -150,6 +151,39 @@ class FbaPricingRepositoryRuleListTest { assertThat(params(wrapper.getValue()).values()).contains(10001L, 88L); } + @Test + void feeSendFromInboundedOnlyChangesFeeStatus() { + when(pricing.update(org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any())) + .thenReturn(1); + + assertThat(repository.markInboundFeeSent(10001L, 88L, "INBOUNDED")).isEqualTo(1); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture()); + assertThat(wrapper.getValue().getSqlSet()).contains("feeStatus").doesNotContain("processStatus"); + assertThat(wrapper.getValue().getCustomSqlSegment()).contains("tenantId", "id", "processStatus", "feeStatus"); + assertThat(params(wrapper.getValue()).values()).contains(10001L, 88L, "INBOUNDED", "CONFIRMED", "SENT"); + } + + @Test + void feeSendFromLegacyFlowAdvancesToWaitingPallet() { + when(pricing.update(org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any())) + .thenReturn(1); + + assertThat(repository.markInboundFeeSent(10001L, 88L, "FEE_CONFIRMING")).isEqualTo(1); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture()); + assertThat(wrapper.getValue().getSqlSet()).contains("feeStatus").contains("processStatus"); + assertThat(wrapper.getValue().getCustomSqlSegment()).contains("tenantId", "id", "processStatus", "feeStatus"); + assertThat(params(wrapper.getValue()).values()) + .contains(10001L, 88L, "FEE_CONFIRMING", "CONFIRMED", "SENT", "WAIT_PALLET"); + } + private Collection queryValues(Long tenantId, String ruleCode) { when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java index 916caef41..996c00a62 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java @@ -90,11 +90,15 @@ class InboundParentChildSqlContractTest { @Test void syncsUnifiedMigration() throws Exception { - String module = normalize(sql).trim(); String unified = normalize(Files.readString(unifiedPath(), StandardCharsets.UTF_8)); assertThat(unified) .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() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java index 63e3d55df..34988b2e7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java @@ -5,10 +5,13 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.MeasurementImageClient; import com.baiyue.firstleg.dto.CartonMeasurementCommand; 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.InboundMeasurementItemEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.repository.InboundMeasurementRepository; +import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -39,17 +42,22 @@ class CartonMeasurementServiceTest { private final CartonLabelBindingResolver resolver = mock(CartonLabelBindingResolver.class); private final MeasurementImageClient images = mock(MeasurementImageClient.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 final AtomicReference insertedItem = new AtomicReference<>(); @BeforeEach void setUp() { - service = new CartonMeasurementService(repository, resolverProvider, images, transactions); + service = new CartonMeasurementService(repository, resolverProvider, images, transactions, + dimensionWeighting); ReflectionTestUtils.setField(service, "internalToken", "internal-token"); when(resolverProvider.getIfAvailable()).thenReturn(resolver); when(resolver.resolveByBoxCode(10001L, "RK-001#5-1")) .thenReturn(new CartonLabelBindingResolver.Binding(70001L, 80001L, "RK-001", 1, 5, "50x40x30cm")); 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(images.upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any())) .thenReturn(ApiResponse.success(new MeasurementImageClient.UploadResult( @@ -79,7 +87,10 @@ class CartonMeasurementServiceTest { assertThat(result.confirmedPackageCount()).isEqualTo(1); assertThat(result.boxCode()).isEqualTo("RK-001#5-1"); 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 @@ -96,7 +107,7 @@ class CartonMeasurementServiceTest { assertThat(result.created()).isFalse(); assertThat(result.confirmedPackageCount()).isEqualTo(7); - assertThat(existing.getLengthCm()).isEqualByComparingTo("60.100"); + assertThat(existing.getLengthCm()).isEqualByComparingTo("59.100"); assertThat(existing.getSourceImageFileId()).isEqualTo(99001L); verify(images).delete("internal-token", 90001L, 10001L, 70001L, 88001L); } @@ -139,6 +150,29 @@ class CartonMeasurementServiceTest { 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 void confirmedPackageCountCanBeCorrectedAfterSubmissionWithoutChangingOriginalPackageCount() { InboundOrderEntity inbound = inbound("MEASURE_PENDING"); @@ -158,13 +192,13 @@ class CartonMeasurementServiceTest { service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 5); assertThat(inbound.getTotalPackageCount()).isEqualTo(5); - assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.3000"); - assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("50.100"); + assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.5250"); + assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("87.675"); verify(repository).updateInbound(inbound); } 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"), "MJ-01", LocalDateTime.parse("2026-08-07T10:30:00"), new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes())); @@ -193,4 +227,15 @@ class CartonMeasurementServiceTest { item.setSourceImageFileId(imageId); item.setItemNo("B1"); item.setBoxSpecification("50x40x30cm"); return item; } + + private List 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(); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java new file mode 100644 index 000000000..6f2434cf3 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java @@ -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 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(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingServiceTest.java new file mode 100644 index 000000000..f36a6b972 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingServiceTest.java @@ -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 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(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 6edd458d7..6585f5a44 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -114,8 +114,6 @@ class FbaPricingServiceTest { readyRepository(); InboundPricingEntity inbound = inbound("100.000", "1.0000"); 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(priceBookEstimator.estimate(any())).thenReturn( estimate("KG", "167.000", "2.000000", "334.00", "0.00")); @@ -205,13 +203,15 @@ class FbaPricingServiceTest { @DisplayName("重复发送只返回首次结果且不会重复写Outbox") void sendIsIdempotentAndDoesNotDuplicateOutbox() { InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("FEE_CONFIRMING"); + inbound.setFeeStatus("CONFIRMED"); InboundFeeEntity confirmed = savedFee("CONFIRMED"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(confirmed); when(repository.findIdempotency(TENANT, OPERATOR, "FEE_SEND", "send-once")) .thenReturn(null, completed("FEE_SEND", 501L)); when(repository.sendFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1); - when(repository.markInboundWaitingPallet(TENANT, INBOUND)).thenReturn(1); + when(repository.markInboundFeeSent(TENANT, INBOUND, "FEE_CONFIRMING")).thenReturn(1); when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1); when(repository.findFee(TENANT, 501L)).thenReturn(confirmed); InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00")); @@ -219,14 +219,40 @@ class FbaPricingServiceTest { doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; }) .when(repository).insertIdempotency(any()); - service.send(TENANT, OPERATOR, INBOUND, "send-once"); - service.send(TENANT, OPERATOR, INBOUND, "send-once"); + FeeSnapshotResponse first = service.send(TENANT, OPERATOR, INBOUND, "send-once"); + FeeSnapshotResponse repeated = service.send(TENANT, OPERATOR, INBOUND, "send-once"); + assertThat(first.nextProcessStatus()).isEqualTo("WAIT_PALLET"); + assertThat(repeated.nextProcessStatus()).isEqualTo("WAIT_PALLET"); verify(repository, times(1)).insertOutbox(any()); verify(repository, times(1)).sendFee(TENANT, 501L, OPERATOR, NOW); verify(feeDetails, times(2)).publishFreight(TENANT, INBOUND, 501L, OPERATOR, null, NOW); } + @Test + @DisplayName("已入库费用发送只发布费用且不回退主流程状态") + void sendsFeeAfterInboundWithoutRollingBackProcessStatus() { + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("INBOUNDED"); + inbound.setFeeStatus("CONFIRMED"); + InboundFeeEntity confirmed = savedFee("CONFIRMED"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(confirmed); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_SEND", "send-inbounded")).thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; }) + .when(repository).insertIdempotency(any()); + when(repository.sendFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1); + when(repository.markInboundFeeSent(TENANT, INBOUND, "INBOUNDED")).thenReturn(1); + when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1); + when(repository.findFee(TENANT, 501L)).thenReturn(confirmed); + + FeeSnapshotResponse response = service.send(TENANT, OPERATOR, INBOUND, "send-inbounded"); + + assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED"); + verify(repository).markInboundFeeSent(TENANT, INBOUND, "INBOUNDED"); + verify(repository).insertOutbox(any()); + } + @Test @DisplayName("同一幂等键用于不同试算时点会冲突") void rejectsReusedIdempotencyKeyWithDifferentRequest() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index 91ac3e60c..658cd50f0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -3,7 +3,6 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.firstleg.client.OrderCargoClient; -import com.baiyue.common.core.api.ApiResponse; import com.baiyue.firstleg.client.MeasurementImageClient; import com.baiyue.firstleg.dto.InboundMeasurementRequests; import com.baiyue.firstleg.dto.MeasurementBatchCommand; @@ -541,7 +540,7 @@ class InboundMeasurementServiceTest { saved.setVersion(0); saved.setStatus("DRAFT"); 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", 2, new BigDecimal("6.500"), new BigDecimal("0.0480")))); when(batchService.saveBatch(any())).thenReturn(saved); @@ -556,7 +555,7 @@ class InboundMeasurementServiceTest { ArgumentCaptor command = ArgumentCaptor.forClass(MeasurementBatchCommand.class); verify(batchService).saveBatch(command.capture()); 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) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java index 6947c8dcc..80adff85a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java @@ -2,10 +2,13 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.exception.BizException; 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.InboundMeasurementItemEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.repository.InboundMeasurementRepository; +import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository; import jakarta.annotation.Resource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -27,14 +30,16 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @DisplayName("测量批次共享领域服务") -@SpringJUnitConfig(MeasurementBatchService.class) +@SpringJUnitConfig({MeasurementBatchService.class, CustomerDimensionWeightingService.class}) class MeasurementBatchServiceTest { @MockBean private InboundMeasurementRepository repository; + @MockBean private CustomerDimensionConfigRepository dimensionConfigs; @Resource private MeasurementBatchService service; @BeforeEach void setUp() { when(repository.findInbound(1L, 10L)).thenReturn(inbound()); + when(dimensionConfigs.findAll(1L)).thenReturn(dimensionRules(1L)); org.mockito.Mockito.doAnswer(invocation -> { InboundMeasurementEntity entity = invocation.getArgument(0); entity.setId(100L); @@ -57,8 +62,13 @@ class MeasurementBatchServiceTest { ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(100L), items.capture()); - assertThat(items.getValue().get(0).getUnitVolumeCbm()).isEqualByComparingTo("0.0240"); - assertThat(items.getValue().get(0).getTotalVolumeCbm()).isEqualByComparingTo("0.0480"); + assertThat(items.getValue().get(0).getUnitVolumeCbm()).isEqualByComparingTo("0.0510"); + 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).getUnitWeightKg()).isNull(); verify(repository, never()).updateInbound(any()); @@ -74,8 +84,8 @@ class MeasurementBatchServiceTest { InboundMeasurementEntity saved = service.saveBatch(command); assertThat(saved.getStatus()).isEqualTo("DRAFT"); - assertThat(saved.getTotalVolumeCbm()).isEqualByComparingTo("0.0480"); - assertThat(saved.getVolumeWeightKg()).isEqualByComparingTo("8.016"); + assertThat(saved.getTotalVolumeCbm()).isEqualByComparingTo("0.1020"); + assertThat(saved.getVolumeWeightKg()).isEqualByComparingTo("17.034"); ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(100L), items.capture()); @@ -145,4 +155,15 @@ class MeasurementBatchServiceTest { inbound.setVersion(0); return inbound; } + + private List 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(); + } } diff --git a/scripts/dimension-ocr-pricing-test-env.ps1 b/scripts/dimension-ocr-pricing-test-env.ps1 new file mode 100644 index 000000000..31e91e572 --- /dev/null +++ b/scripts/dimension-ocr-pricing-test-env.ps1 @@ -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 }