diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index f4189c224..513ae90b7 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -3203,6 +3203,21 @@ CREATE TABLE IF NOT EXISTS fl_price_origin_alias ( KEY idx_price_origin_alias_name (alias_name, status) ) ENGINE=InnoDB COMMENT='价格表起运仓名称别名映射'; +CREATE TABLE IF NOT EXISTS fl_price_warehouse_mapping ( + id BIGINT UNSIGNED NOT NULL COMMENT '价格仓映射ID', + source_type VARCHAR(24) NOT NULL COMMENT '来源维度:ORIGIN_CODE或OVERSEAS_NAME', + source_value VARCHAR(128) NOT NULL COMMENT '解析后的来源仓编码或海外仓名称', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '固定平台公共仓库ID', + warehouse_code VARCHAR(64) NOT NULL COMMENT '平台仓库稳定编码快照', + warehouse_type VARCHAR(16) NOT NULL COMMENT 'DOMESTIC或OVERSEAS', + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_price_warehouse_mapping_source (source_type, source_value), + KEY idx_price_warehouse_mapping_target (warehouse_id, status) +) ENGINE=InnoDB COMMENT='价格表来源仓与固定平台公共仓映射'; + CREATE TABLE IF NOT EXISTS fl_price_book_version ( id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共价格作用域固定为0', @@ -3287,9 +3302,21 @@ VALUES (3, 'CN-YIWU-01', '义乌', 'ACTIVE'), (4, 'CN-DONGGUAN-01', '华南仓(深圳)', 'ACTIVE'), (5, 'CN-DONGGUAN-01', '东莞仓', 'ACTIVE'), - (6, 'CN-DONGGUAN-01', '深圳仓', 'ACTIVE') + (6, 'CN-DONGGUAN-01', '深圳仓', 'ACTIVE'), + (7, 'CN-DONGGUAN-01', '华南仓(深圳/东莞)', 'ACTIVE') ON DUPLICATE KEY UPDATE status = VALUES(status); +INSERT INTO fl_price_warehouse_mapping + (id, source_type, source_value, warehouse_id, warehouse_code, warehouse_type, status) +VALUES + (1, 'ORIGIN_CODE', 'CN-YIWU-01', 193000000000200001, 'CN-YIWU-01', 'DOMESTIC', 'ACTIVE'), + (2, 'ORIGIN_CODE', 'CN-DONGGUAN-01', 193000000000200002, 'CN-DONGGUAN-01', 'DOMESTIC', 'ACTIVE'), + (3, 'OVERSEAS_NAME', '洛杉矶仓', 193000000000200003, 'US-LAX-01', 'OVERSEAS', 'ACTIVE'), + (4, 'OVERSEAS_NAME', '纽约仓', 193000000000200004, 'US-NYC-01', 'OVERSEAS', 'ACTIVE') +ON DUPLICATE KEY UPDATE + warehouse_id = VALUES(warehouse_id), warehouse_code = VALUES(warehouse_code), + warehouse_type = VALUES(warehouse_type), status = VALUES(status); + -- ============================================================================ -- 来源:iam\src\main\resources\db\phase2-inbound-query-permissions.sql -- ============================================================================ @@ -3676,6 +3703,7 @@ CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'pricing_tax_m CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_measurement_item', 'external_detail_no'); CALL unified_assert_table('baiyue_firstleg', 'fl_price_channel_catalog'); CALL unified_assert_table('baiyue_firstleg', 'fl_price_origin_alias'); +CALL unified_assert_table('baiyue_firstleg', 'fl_price_warehouse_mapping'); CALL unified_assert_table('baiyue_firstleg', 'fl_price_book_version'); CALL unified_assert_column('baiyue_firstleg', 'fl_price_book_version', 'parse_issues_json'); CALL unified_assert_table('baiyue_firstleg', 'fl_price_book_channel'); diff --git a/file/src/main/java/com/baiyue/file/client/WeightOcrClient.java b/file/src/main/java/com/baiyue/file/client/WeightOcrClient.java index b0528236a..c29a9d72d 100644 --- a/file/src/main/java/com/baiyue/file/client/WeightOcrClient.java +++ b/file/src/main/java/com/baiyue/file/client/WeightOcrClient.java @@ -17,7 +17,6 @@ public interface WeightOcrClient { ApiResponse recognize(@RequestHeader("X-Internal-Service-Token") String token, @RequestPart("file") MultipartFile file); - record Result(String sourceImage, String croppedImage, String rawResponseFile, - String status, BigDecimal weight, Double confidence, - java.util.List candidates, String error) {} + record Result(String status, BigDecimal weight, BigDecimal confidence, + String ocrRawText, String error) {} } diff --git a/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java b/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java index 56608aa6e..86532313d 100644 --- a/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java +++ b/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java @@ -18,6 +18,7 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDateTime; @Service @@ -39,7 +40,7 @@ public class MeasurementImageService { FileObjectEntity object = storeObject( tenantId, inboundId, operatorId, file, "INBOUND_MEASUREMENT_IMAGE"); return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(), - object.getSizeBytes(), "NOT_REQUIRED", null, null, null, null); + object.getSizeBytes(), "NOT_REQUIRED", null, null, null, null, null); } private FileObjectEntity storeObject(long tenantId, long inboundId, long operatorId, @@ -116,7 +117,8 @@ public class MeasurementImageService { } var result = response.data(); return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(), - object.getSizeBytes(), result.status(), result.weight(), "KG", result.confidence(), result.error()); + object.getSizeBytes(), result.status(), result.weight(), "KG", + normalizeConfidence(result.confidence()), result.ocrRawText(), result.error()); } catch (FeignException exception) { return failed(object, "OCR服务不可用,可稍后重试或人工录入"); } @@ -124,12 +126,19 @@ public class MeasurementImageService { private UploadResult failed(FileObjectEntity object, String message) { return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(), - object.getSizeBytes(), "FAILED", null, null, null, message); + object.getSizeBytes(), "FAILED", null, null, null, null, message); + } + + private BigDecimal normalizeConfidence(BigDecimal value) { + if (value == null) return null; + if (value.compareTo(BigDecimal.ZERO) < 0) value = BigDecimal.ZERO; + if (value.compareTo(BigDecimal.ONE) > 0) value = BigDecimal.ONE; + return value.setScale(5, RoundingMode.HALF_UP); } public record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes, String recognitionStatus, BigDecimal weightValue, String weightUnit, - Double confidence, String error) {} + BigDecimal confidence, String ocrRawText, String error) {} public record Download(String fileName, String contentType, byte[] content) {} private record StoredImageMultipartFile(String originalFilename, String contentType, byte[] bytes) diff --git a/file/src/test/java/com/baiyue/file/controller/InternalMeasurementImageControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalMeasurementImageControllerTest.java index 53f081a3c..42c57ce8f 100644 --- a/file/src/test/java/com/baiyue/file/controller/InternalMeasurementImageControllerTest.java +++ b/file/src/test/java/com/baiyue/file/controller/InternalMeasurementImageControllerTest.java @@ -29,7 +29,7 @@ class InternalMeasurementImageControllerTest { var file = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE, new byte[]{1, 2, 3}); var result = new MeasurementImageService.UploadResult(41L, "measure.png", - MediaType.IMAGE_PNG_VALUE, 3L, "NOT_REQUIRED", null, null, null, null); + MediaType.IMAGE_PNG_VALUE, 3L, "NOT_REQUIRED", null, null, null, null, null); when(images.uploadMeasurementEvidence(11L, 21L, 31L, file)).thenReturn(result); var response = controller.upload("internal-token", 31L, 11L, 21L, file); diff --git a/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java b/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java index 0b35d4405..8b0b3ec3c 100644 --- a/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java +++ b/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java @@ -1,5 +1,6 @@ package com.baiyue.file.service; +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.file.client.WeightOcrClient; import com.baiyue.file.entity.FileObjectEntity; import com.baiyue.file.mapper.FileObjectMapper; @@ -18,6 +19,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import jakarta.annotation.Resource; import java.nio.file.Path; +import java.math.BigDecimal; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -91,4 +93,27 @@ class MeasurementImageServiceEvidenceTest { .isInstanceOf(com.baiyue.common.core.exception.BizException.class); verifyNoInteractions(storage); } + + @Test + void forwardsSafeOcrTextAndCanonicalConfidence() { + FileObjectEntity object = new FileObjectEntity(); + object.setId(41L); + object.setTenantId(11L); + object.setBusinessType("INBOUND_WEIGHT_IMAGE"); + object.setBusinessId("21"); + object.setOriginalName("weight.jpg"); + object.setContentType(MediaType.IMAGE_JPEG_VALUE); + object.setObjectKey("measurements/11/21/weight.jpg"); + object.setSizeBytes(3L); + when(files.selectOne(any())).thenReturn(object); + when(storage.read(object.getObjectKey())).thenReturn(new byte[]{1, 2, 3}); + when(ocr.recognize(org.mockito.ArgumentMatchers.eq("internal-token"), any())) + .thenReturn(ApiResponse.success(new WeightOcrClient.Result("RECOGNIZED", + new BigDecimal("285.5"), new BigDecimal("0.5097191334"), "285.5\nKG", null))); + + var result = service.retryRecognition(11L, 21L, 41L); + + assertThat(result.confidence()).isEqualByComparingTo("0.50972"); + assertThat(result.ocrRawText()).isEqualTo("285.5\nKG"); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceWarehouseMappingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceWarehouseMappingEntity.java new file mode 100644 index 000000000..15e461d17 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceWarehouseMappingEntity.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@TableName("fl_price_warehouse_mapping") +public class PriceWarehouseMappingEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String sourceType; + private String sourceValue; + private Long warehouseId; + private String warehouseCode; + private String warehouseType; + private String status; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceWarehouseMappingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceWarehouseMappingMapper.java new file mode 100644 index 000000000..1be85c8ec --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceWarehouseMappingMapper.java @@ -0,0 +1,6 @@ +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.entity.PriceWarehouseMappingEntity; + +public interface PriceWarehouseMappingMapper extends BaseMapper {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java index 42e4adfb2..188b90172 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java @@ -7,11 +7,13 @@ import com.baiyue.firstleg.entity.PriceBookChannelEntity; import com.baiyue.firstleg.entity.PriceBookVersionEntity; import com.baiyue.firstleg.entity.PriceChannelCatalogEntity; import com.baiyue.firstleg.entity.PriceOriginAliasEntity; +import com.baiyue.firstleg.entity.PriceWarehouseMappingEntity; import com.baiyue.firstleg.mapper.CarrierFbaRateMapper; import com.baiyue.firstleg.mapper.PriceBookChannelMapper; import com.baiyue.firstleg.mapper.PriceBookVersionMapper; import com.baiyue.firstleg.mapper.PriceChannelCatalogMapper; import com.baiyue.firstleg.mapper.PriceOriginAliasMapper; +import com.baiyue.firstleg.mapper.PriceWarehouseMappingMapper; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; @@ -25,6 +27,7 @@ public class PriceBookRepository { @Resource private CarrierFbaRateMapper rates; @Resource private PriceChannelCatalogMapper catalog; @Resource private PriceOriginAliasMapper aliases; + @Resource private PriceWarehouseMappingMapper warehouseMappings; public int insertVersion(PriceBookVersionEntity value) { return versions.insert(value); } public int updateVersion(PriceBookVersionEntity value) { @@ -109,6 +112,11 @@ public class PriceBookRepository { return aliases.selectList(new LambdaQueryWrapper() .eq(PriceOriginAliasEntity::getStatus, "ACTIVE")); } + public List activeWarehouseMappings() { + return warehouseMappings.selectList(new LambdaQueryWrapper() + .eq(PriceWarehouseMappingEntity::getStatus, "ACTIVE") + .orderByAsc(PriceWarehouseMappingEntity::getId)); + } public void supersede(PriceBookVersionEntity old, LocalDateTime at, long operatorId) { old.setStatus("SUPERSEDED"); old.setSupersededAt(at); old.setUpdatedBy(operatorId); old.setUpdatedAt(at); updateVersion(old); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java index 0b9a768c1..ee121a0a2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java @@ -16,6 +16,7 @@ import com.baiyue.firstleg.entity.CarrierFbaRateEntity; import com.baiyue.firstleg.entity.PriceBookChannelEntity; import com.baiyue.firstleg.entity.PriceBookVersionEntity; import com.baiyue.firstleg.entity.PriceChannelCatalogEntity; +import com.baiyue.firstleg.entity.PriceWarehouseMappingEntity; import com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult; import com.baiyue.firstleg.pricing.importer.PriceWorkbookParser; import com.baiyue.firstleg.repository.PriceBookRepository; @@ -262,7 +263,37 @@ public class PlatformPriceBookService { List issues = parsed.issues().stream().map(value -> issue( value.severity() == PriceWorkbookParseResult.Severity.ERROR ? "BLOCKING" : "WARNING", value.code(), value.message(), value.sheetName(), value.rowNumber(), null)).toList(); - return new ParsedPriceBook(channels, rates, issues); + return new ParsedPriceBook(channels, applyInitializedWarehouseMappings(rates), issues); + } + + private List applyInitializedWarehouseMappings(List rates) { + Map origins = new HashMap<>(); + Map overseas = new HashMap<>(); + for (PriceWarehouseMappingEntity mapping : repository.activeWarehouseMappings()) { + if (mapping == null || mapping.getWarehouseId() == null || mapping.getWarehouseId() <= 0) continue; + if ("ORIGIN_CODE".equals(mapping.getSourceType()) + && "DOMESTIC".equals(mapping.getWarehouseType()) + && mappingKey(mapping.getSourceValue()).equals(mappingKey(mapping.getWarehouseCode()))) { + origins.putIfAbsent(mappingKey(mapping.getSourceValue()), mapping); + } else if ("OVERSEAS_NAME".equals(mapping.getSourceType()) + && "OVERSEAS".equals(mapping.getWarehouseType()) + && StringUtils.hasText(mapping.getWarehouseCode())) { + overseas.putIfAbsent(mappingKey(mapping.getSourceValue()), mapping); + } + } + return rates.stream().map(rate -> { + PriceWarehouseMappingEntity origin = origins.get(mappingKey(rate.originWarehouseCode())); + PriceWarehouseMappingEntity destination = rate.destinationType() == PricingDestinationType.OVERSEAS + ? overseas.get(mappingKey(rate.destinationName())) : null; + return new PriceBookDtos.ParsedRate(rate.id(), rate.channelCode(), rate.priceModel(), + origin == null ? rate.originWarehouseId() : origin.getWarehouseId(), + rate.originWarehouseCode(), rate.originWarehouseName(), + destination == null ? rate.destinationCode() : mappingKey(destination.getWarehouseCode()), rate.destinationType(), + destination == null ? rate.destinationWarehouseId() : destination.getWarehouseId(), + rate.destinationName(), rate.regionCode(), rate.pricingTaxMode(), rate.chargeUnit(), rate.weightFromKg(), + rate.unitPrice(), rate.currency(), rate.sourceSheetName(), rate.sourceRowNumber(), + rate.sourceDestinationText(), rate.remark()); + }).toList(); } private PriceBookDtos.ParsedRate parsedRate(String channelCode, PriceModel model, diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index a72ee5706..a00d14c07 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -411,6 +411,21 @@ CREATE TABLE IF NOT EXISTS fl_price_origin_alias ( KEY idx_price_origin_alias_name (alias_name, status) ) ENGINE=InnoDB COMMENT='价格表起运仓名称别名映射'; +CREATE TABLE IF NOT EXISTS fl_price_warehouse_mapping ( + id BIGINT UNSIGNED NOT NULL COMMENT '价格仓映射ID', + source_type VARCHAR(24) NOT NULL COMMENT '来源维度:ORIGIN_CODE或OVERSEAS_NAME', + source_value VARCHAR(128) NOT NULL COMMENT '解析后的来源仓编码或海外仓名称', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '固定平台公共仓库ID', + warehouse_code VARCHAR(64) NOT NULL COMMENT '平台仓库稳定编码快照', + warehouse_type VARCHAR(16) NOT NULL COMMENT 'DOMESTIC或OVERSEAS', + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_price_warehouse_mapping_source (source_type, source_value), + KEY idx_price_warehouse_mapping_target (warehouse_id, status) +) ENGINE=InnoDB COMMENT='价格表来源仓与固定平台公共仓映射'; + CREATE TABLE IF NOT EXISTS fl_price_book_version ( id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共价格作用域固定为0', @@ -494,9 +509,21 @@ VALUES (3, 'CN-YIWU-01', '义乌', 'ACTIVE'), (4, 'CN-DONGGUAN-01', '华南仓(深圳)', 'ACTIVE'), (5, 'CN-DONGGUAN-01', '东莞仓', 'ACTIVE'), - (6, 'CN-DONGGUAN-01', '深圳仓', 'ACTIVE') + (6, 'CN-DONGGUAN-01', '深圳仓', 'ACTIVE'), + (7, 'CN-DONGGUAN-01', '华南仓(深圳/东莞)', 'ACTIVE') ON DUPLICATE KEY UPDATE status = VALUES(status); +INSERT INTO fl_price_warehouse_mapping + (id, source_type, source_value, warehouse_id, warehouse_code, warehouse_type, status) +VALUES + (1, 'ORIGIN_CODE', 'CN-YIWU-01', 193000000000200001, 'CN-YIWU-01', 'DOMESTIC', 'ACTIVE'), + (2, 'ORIGIN_CODE', 'CN-DONGGUAN-01', 193000000000200002, 'CN-DONGGUAN-01', 'DOMESTIC', 'ACTIVE'), + (3, 'OVERSEAS_NAME', '洛杉矶仓', 193000000000200003, 'US-LAX-01', 'OVERSEAS', 'ACTIVE'), + (4, 'OVERSEAS_NAME', '纽约仓', 193000000000200004, 'US-NYC-01', 'OVERSEAS', 'ACTIVE') +ON DUPLICATE KEY UPDATE + warehouse_id = VALUES(warehouse_id), warehouse_code = VALUES(warehouse_code), + warehouse_type = VALUES(warehouse_type), status = VALUES(status); + CREATE TABLE IF NOT EXISTS fl_inbound_fee ( id BIGINT UNSIGNED NOT NULL COMMENT '入库费用ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java index e7958817d..3ef7e2a0f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java @@ -28,6 +28,8 @@ class PriceBookSqlContractTest { assertThat(schema) .contains("CREATE TABLE IF NOT EXISTS fl_price_channel_catalog") .contains("CREATE TABLE IF NOT EXISTS fl_price_origin_alias") + .contains("CREATE TABLE IF NOT EXISTS fl_price_warehouse_mapping") + .contains("华南仓(深圳/东莞)") .contains("CREATE TABLE IF NOT EXISTS fl_price_book_version") .contains("CREATE TABLE IF NOT EXISTS fl_price_book_channel") .contains("price_book_version_id") @@ -40,6 +42,11 @@ class PriceBookSqlContractTest { .contains("destination_name VARCHAR(128)") .contains("parse_issues_json JSON NULL") .contains("DEFAULT 'PENDING_CONFIRMATION'"); + assertThat(schema) + .contains("'ORIGIN_CODE', 'CN-YIWU-01', 193000000000200001") + .contains("'ORIGIN_CODE', 'CN-DONGGUAN-01', 193000000000200002") + .contains("'OVERSEAS_NAME', '洛杉矶仓', 193000000000200003") + .contains("'OVERSEAS_NAME', '纽约仓', 193000000000200004"); assertThat(channelTable) .contains("created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID'") .contains("updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID'") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java index 2e4d70189..42652dc2d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java @@ -14,6 +14,7 @@ import com.baiyue.firstleg.entity.CarrierFbaRateEntity; import com.baiyue.firstleg.entity.PriceBookChannelEntity; import com.baiyue.firstleg.entity.PriceBookVersionEntity; import com.baiyue.firstleg.entity.PriceChannelCatalogEntity; +import com.baiyue.firstleg.entity.PriceWarehouseMappingEntity; import com.baiyue.firstleg.repository.PriceBookRepository; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; @@ -230,6 +231,29 @@ class PlatformPriceBookServiceTest { .doesNotContain("DUPLICATE_RATE"); } + @Test + void initializedWarehouseMappingsBindFixedDomesticAndOverseasWarehouses() { + CarrierFbaRateEntity overseasRate = rate(81L); + overseasRate.setDestinationType(PricingDestinationType.OVERSEAS.name()); + overseasRate.setDestinationCode(null); + overseasRate.setDestinationName("洛杉矶仓"); + PriceBookDtos.ParsedRate parsed = ReflectionTestUtils.invokeMethod(service, "rateDto", overseasRate); + when(repository.activeWarehouseMappings()).thenReturn(List.of( + warehouseMapping("ORIGIN_CODE", "CN-YIWU-01", 193000000000200001L, "CN-YIWU-01", "DOMESTIC"), + warehouseMapping("OVERSEAS_NAME", "洛杉矶仓", 193000000000200003L, "US-LAX-01", "OVERSEAS") + )); + + @SuppressWarnings("unchecked") + List mapped = ReflectionTestUtils.invokeMethod( + service, "applyInitializedWarehouseMappings", List.of(parsed)); + + assertThat(mapped).singleElement().satisfies(rate -> { + assertThat(rate.originWarehouseId()).isEqualTo(193000000000200001L); + assertThat(rate.destinationWarehouseId()).isEqualTo(193000000000200003L); + assertThat(rate.destinationCode()).isEqualTo("US-LAX-01"); + }); + } + @Test void duplicatePublishIsRejectedAfterPublishLock() { when(repository.findVersion(31L)).thenReturn(version(31L, PriceBookStatus.ACTIVE)); @@ -300,6 +324,18 @@ class PlatformPriceBookServiceTest { List.of(new PriceBookConfirmationRequest.OriginMapping(code, id)), List.of()); } + private static PriceWarehouseMappingEntity warehouseMapping(String sourceType, String sourceValue, long warehouseId, + String warehouseCode, String warehouseType) { + PriceWarehouseMappingEntity value = new PriceWarehouseMappingEntity(); + value.setSourceType(sourceType); + value.setSourceValue(sourceValue); + value.setWarehouseId(warehouseId); + value.setWarehouseCode(warehouseCode); + value.setWarehouseType(warehouseType); + value.setStatus("ACTIVE"); + return value; + } + private void executeTransactionsImmediately() { when(transactions.execute(any())).thenAnswer(invocation -> { @SuppressWarnings("unchecked") diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/ocr/OcrCandidate.java b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/OcrCandidate.java index d734a2be1..128df4b83 100644 --- a/integration-service/src/main/java/com/baiyue/integration/dto/ocr/OcrCandidate.java +++ b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/OcrCandidate.java @@ -1,16 +1,22 @@ package com.baiyue.integration.dto.ocr; +import java.math.BigDecimal; + /** * 百度 OCR 返回的文字候选及其识别框。 */ public record OcrCandidate( String text, - Double confidence, + BigDecimal confidence, Integer left, Integer top, Integer width, Integer height ) { + public OcrCandidate { + confidence = OcrConfidence.normalize(confidence); + } + public long area() { if (width == null || height == null || width <= 0 || height <= 0) { return 0; diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/ocr/OcrConfidence.java b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/OcrConfidence.java new file mode 100644 index 000000000..3d8dbf4ee --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/OcrConfidence.java @@ -0,0 +1,27 @@ +package com.baiyue.integration.dto.ocr; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** Canonical confidence representation shared by OCR response models. */ +public final class OcrConfidence { + private static final int SCALE = 5; + private static final BigDecimal ZERO = BigDecimal.ZERO.setScale(SCALE); + private static final BigDecimal ONE = BigDecimal.ONE.setScale(SCALE); + + private OcrConfidence() { + } + + public static BigDecimal normalize(BigDecimal value) { + if (value == null) { + return null; + } + if (value.compareTo(BigDecimal.ZERO) < 0) { + return ZERO; + } + if (value.compareTo(BigDecimal.ONE) > 0) { + return ONE; + } + return value.setScale(SCALE, RoundingMode.HALF_UP); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrImageResult.java b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrImageResult.java index 21b99715a..779bf8fbd 100644 --- a/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrImageResult.java +++ b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrImageResult.java @@ -12,10 +12,15 @@ public record WeightOcrImageResult( String rawResponseFile, Status status, BigDecimal weight, - Double confidence, + BigDecimal confidence, List candidates, + String ocrRawText, String error ) { + public WeightOcrImageResult { + confidence = OcrConfidence.normalize(confidence); + } + public enum Status { RECOGNIZED, NO_WEIGHT_FOUND, diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java index ac36aa6fb..2c047dc3a 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java @@ -12,6 +12,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Component; import java.io.IOException; +import java.math.BigDecimal; import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; @@ -163,7 +164,7 @@ public class BaiduMeterClient { JsonNode location = item.path("location"); candidates.add(new OcrCandidate( item.path("words").asText(), - nullableDouble(probability.get("average")), + nullableDecimal(probability.get("average")), nullableInt(location.get("left")), nullableInt(location.get("top")), nullableInt(location.get("width")), @@ -195,8 +196,8 @@ public class BaiduMeterClient { return URLEncoder.encode(value, StandardCharsets.UTF_8); } - private static Double nullableDouble(JsonNode node) { - return node == null || !node.isNumber() ? null : node.doubleValue(); + private static BigDecimal nullableDecimal(JsonNode node) { + return node == null || !node.isNumber() ? null : node.decimalValue(); } private static Integer nullableInt(JsonNode node) { diff --git a/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightCandidateSelector.java b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightCandidateSelector.java index 5c6e30761..4a0c0332a 100644 --- a/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightCandidateSelector.java +++ b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightCandidateSelector.java @@ -25,7 +25,8 @@ public class WeightCandidateSelector { .flatMap(Optional::stream) .max(Comparator .comparingLong((SelectedWeight selected) -> selected.candidate().area()) - .thenComparing(selected -> selected.confidence() == null ? -1 : selected.confidence())); + .thenComparing(selected -> selected.confidence() == null + ? BigDecimal.valueOf(-1) : selected.confidence())); } private Optional toSelectedWeight(OcrCandidate candidate) { @@ -55,6 +56,6 @@ public class WeightCandidateSelector { .replace('-', '-'); } - public record SelectedWeight(BigDecimal weight, Double confidence, OcrCandidate candidate) { + public record SelectedWeight(BigDecimal weight, BigDecimal confidence, OcrCandidate candidate) { } } diff --git a/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrBatchService.java b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrBatchService.java index 7ddfb49cb..5941ecada 100644 --- a/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrBatchService.java +++ b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrBatchService.java @@ -34,6 +34,7 @@ import java.util.stream.Stream; @Service public class WeightOcrBatchService { private static final Set SUPPORTED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "bmp"); + private static final int RAW_TEXT_MAX_LENGTH = 1000; @Resource private IntegrationProperties properties; @Resource private WeightOcrStoragePolicy storagePolicy; @@ -113,7 +114,7 @@ public class WeightOcrBatchService { return new WeightOcrImageResult( source.toString(), outputRelative(paths, cropped), outputRelative(paths, rawResponse), Status.RECOGNIZED, selected.weight(), selected.confidence(), - response.candidates(), null); + response.candidates(), summarizeCandidates(response.candidates()), null); } private WeightOcrImageResult noWeight(Path source, BatchPaths paths, Path cropped, @@ -121,6 +122,7 @@ public class WeightOcrBatchService { return new WeightOcrImageResult( source.toString(), outputRelative(paths, cropped), outputRelative(paths, rawResponse), Status.NO_WEIGHT_FOUND, null, null, response.candidates(), + summarizeCandidates(response.candidates()), "百度响应中没有符合重量格式的候选值"); } @@ -129,7 +131,33 @@ public class WeightOcrBatchService { return new WeightOcrImageResult( source.toString(), Files.exists(cropped) ? outputRelative(paths, cropped) : null, Files.exists(rawResponse) ? outputRelative(paths, rawResponse) : null, - Status.FAILED, null, null, List.of(), error); + Status.FAILED, null, null, List.of(), null, error); + } + + private String summarizeCandidates(List candidates) { + if (candidates == null || candidates.isEmpty()) { + return null; + } + StringBuilder summary = new StringBuilder(); + for (var candidate : candidates) { + if (candidate == null || candidate.text() == null) { + continue; + } + String text = candidate.text().strip().replaceAll("[\\p{Cc}\\p{Cf}]+", " "); + if (text.isBlank()) { + continue; + } + int separatorLength = summary.isEmpty() ? 0 : 1; + int remaining = RAW_TEXT_MAX_LENGTH - summary.length() - separatorLength; + if (remaining <= 0) { + break; + } + if (separatorLength > 0) { + summary.append('\n'); + } + summary.append(text, 0, Math.min(text.length(), remaining)); + } + return summary.isEmpty() ? null : summary.toString(); } private WeightOcrBatchResult buildResult(String batchId, Instant startedAt, BatchPaths paths, diff --git a/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrSingleImageService.java b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrSingleImageService.java index 991c111fe..dde470ecf 100644 --- a/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrSingleImageService.java +++ b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrSingleImageService.java @@ -56,7 +56,9 @@ public class WeightOcrSingleImageService { if (batch.results() == null || batch.results().size() != 1) { throw new BizException(ResultCode.INTERNAL_ERROR, "OCR单图识别结果数量异常"); } - return batch.results().get(0); + WeightOcrImageResult result = batch.results().get(0); + return new WeightOcrImageResult(null, null, null, result.status(), result.weight(), + result.confidence(), java.util.List.of(), result.ocrRawText(), result.error()); } finally { deleteRequestDirectory(requestDirectory); } diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightCandidateSelectorTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightCandidateSelectorTest.java index aca000d3b..e85c67661 100644 --- a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightCandidateSelectorTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightCandidateSelectorTest.java @@ -4,6 +4,7 @@ import com.baiyue.integration.dto.ocr.OcrCandidate; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.math.BigDecimal; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -15,21 +16,31 @@ class WeightCandidateSelectorTest { @Test void selectsLargestNumericCandidateAndNormalizesDecimalSeparator() { List candidates = List.of( - new OcrCandidate("10:38", 0.99, 10, 10, 40, 10), - new OcrCandidate(" 12,35 ", 0.97, 20, 30, 120, 60), - new OcrCandidate("29", 0.99, 160, 10, 20, 10), - new OcrCandidate("kg", 0.99, 150, 70, 30, 15)); + new OcrCandidate("10:38", new BigDecimal("0.99"), 10, 10, 40, 10), + new OcrCandidate(" 12,35 ", new BigDecimal("0.97"), 20, 30, 120, 60), + new OcrCandidate("29", new BigDecimal("0.99"), 160, 10, 20, 10), + new OcrCandidate("kg", new BigDecimal("0.99"), 150, 70, 30, 15)); WeightCandidateSelector.SelectedWeight selected = selector.select(candidates).orElseThrow(); assertThat(selected.weight()).isEqualByComparingTo("12.35"); - assertThat(selected.confidence()).isEqualTo(0.97); + assertThat(selected.confidence()).isEqualByComparingTo("0.97000"); } @Test void rejectsNonNumericCandidates() { assertThat(selector.select(List.of( - new OcrCandidate("kg", 0.99, 0, 0, 20, 10), - new OcrCandidate("10:38", 0.99, 0, 0, 20, 10)))).isEmpty(); + new OcrCandidate("kg", new BigDecimal("0.99"), 0, 0, 20, 10), + new OcrCandidate("10:38", new BigDecimal("0.99"), 0, 0, 20, 10)))).isEmpty(); + } + + @Test + void canonicalizesConfidenceToRangeAndFiveDecimalPlaces() { + assertThat(new OcrCandidate("1", new BigDecimal("0.5097191334"), 0, 0, 1, 1) + .confidence()).isEqualByComparingTo("0.50972"); + assertThat(new OcrCandidate("1", new BigDecimal("1.2"), 0, 0, 1, 1) + .confidence()).isEqualByComparingTo("1.00000"); + assertThat(new OcrCandidate("1", new BigDecimal("-0.1"), 0, 0, 1, 1) + .confidence()).isEqualByComparingTo("0.00000"); } } diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java index efe4fdc91..52c211f71 100644 --- a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java @@ -19,6 +19,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import javax.imageio.ImageIO; import java.awt.Color; import java.awt.image.BufferedImage; +import java.math.BigDecimal; import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; @@ -56,13 +57,17 @@ class WeightOcrBatchServiceTest { when(properties.baiduOcr()).thenReturn(properties(inputRoot, outputRoot).baiduOcr()); when(client.recognize(any())).thenReturn(new BaiduMeterClient.MeterResponse( "{\"words_result\":[]}", - List.of(new OcrCandidate("12.35", 0.98, 10, 10, 80, 30)))); + List.of( + new OcrCandidate("12.35", new BigDecimal("0.5097191334"), 10, 10, 80, 30), + new OcrCandidate("KG\u0000", new BigDecimal("0.98"), 10, 50, 30, 10)))); var result = service.recognize(new WeightOcrBatchRequest("batch-a", null, null, null, false)); assertThat(result.total()).isEqualTo(1); assertThat(result.recognized()).isEqualTo(1); assertThat(result.results().get(0).status()).isEqualTo(Status.RECOGNIZED); + assertThat(result.results().get(0).confidence()).isEqualByComparingTo("0.50972"); + assertThat(result.results().get(0).ocrRawText()).isEqualTo("12.35\nKG "); Path batchOutput = outputRoot.resolve(result.outputDirectory()); assertThat(batchOutput.resolve("cropped/weight.jpg")).isRegularFile(); assertThat(batchOutput.resolve("raw/weight.json")).isRegularFile(); diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrSingleImageServiceTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrSingleImageServiceTest.java new file mode 100644 index 000000000..fe2317f3d --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrSingleImageServiceTest.java @@ -0,0 +1,58 @@ +package com.baiyue.integration.service.ocr; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.ocr.OcrCandidate; +import com.baiyue.integration.dto.ocr.WeightOcrBatchResult; +import com.baiyue.integration.dto.ocr.WeightOcrImageResult; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.util.unit.DataSize; + +import java.math.BigDecimal; +import java.net.URI; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(WeightOcrSingleImageService.class) +class WeightOcrSingleImageServiceTest { + @TempDir Path tempDirectory; + @MockBean IntegrationProperties properties; + @MockBean WeightOcrBatchService batches; + @Resource WeightOcrSingleImageService service; + + @Test + void returnsSafeContractWithoutTemporaryPathsOrCandidateDetails() throws Exception { + var config = new IntegrationProperties.BaiduOcr(true, "key", "secret", + URI.create("https://example.com/token"), URI.create("https://example.com/meter"), + Duration.ofSeconds(1), Duration.ofSeconds(1), tempDirectory, tempDirectory.resolve("out"), + DataSize.ofMegabytes(3), 40_000_000L, 50, Duration.ZERO, 0); + when(properties.baiduOcr()).thenReturn(config); + var imageResult = new WeightOcrImageResult("weight.jpg", "cropped/weight.jpg", "raw/weight.json", + WeightOcrImageResult.Status.RECOGNIZED, new BigDecimal("285.5"), + new BigDecimal("0.5097191334"), + List.of(new OcrCandidate("285.5", new BigDecimal("0.5097191334"), 0, 0, 10, 10)), + "285.5", null); + when(batches.recognize(any())).thenReturn(new WeightOcrBatchResult("batch", Instant.now(), + Instant.now(), "input", "output", 1, 1, 0, 0, List.of(imageResult))); + + var result = service.recognize(new MockMultipartFile( + "file", "weight.jpg", "image/jpeg", new byte[]{1, 2, 3})); + + assertThat(result.confidence()).isEqualByComparingTo("0.50972"); + assertThat(result.ocrRawText()).isEqualTo("285.5"); + assertThat(result.sourceImage()).isNull(); + assertThat(result.croppedImage()).isNull(); + assertThat(result.rawResponseFile()).isNull(); + assertThat(result.candidates()).isEmpty(); + } +}