This commit is contained in:
2026-08-07 17:46:23 +08:00
parent 98ac9840c2
commit 645c483649
22 changed files with 370 additions and 29 deletions
@@ -3203,6 +3203,21 @@ CREATE TABLE IF NOT EXISTS fl_price_origin_alias (
KEY idx_price_origin_alias_name (alias_name, status) KEY idx_price_origin_alias_name (alias_name, status)
) ENGINE=InnoDB COMMENT='价格表起运仓名称别名映射'; ) 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 ( CREATE TABLE IF NOT EXISTS fl_price_book_version (
id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID', id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID',
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共价格作用域固定为0', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共价格作用域固定为0',
@@ -3287,9 +3302,21 @@ VALUES
(3, 'CN-YIWU-01', '义乌', 'ACTIVE'), (3, 'CN-YIWU-01', '义乌', 'ACTIVE'),
(4, 'CN-DONGGUAN-01', '华南仓(深圳)', 'ACTIVE'), (4, 'CN-DONGGUAN-01', '华南仓(深圳)', 'ACTIVE'),
(5, '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); 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 -- 来源: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_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_channel_catalog');
CALL unified_assert_table('baiyue_firstleg', 'fl_price_origin_alias'); 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_table('baiyue_firstleg', 'fl_price_book_version');
CALL unified_assert_column('baiyue_firstleg', 'fl_price_book_version', 'parse_issues_json'); CALL unified_assert_column('baiyue_firstleg', 'fl_price_book_version', 'parse_issues_json');
CALL unified_assert_table('baiyue_firstleg', 'fl_price_book_channel'); CALL unified_assert_table('baiyue_firstleg', 'fl_price_book_channel');
@@ -17,7 +17,6 @@ public interface WeightOcrClient {
ApiResponse<Result> recognize(@RequestHeader("X-Internal-Service-Token") String token, ApiResponse<Result> recognize(@RequestHeader("X-Internal-Service-Token") String token,
@RequestPart("file") MultipartFile file); @RequestPart("file") MultipartFile file);
record Result(String sourceImage, String croppedImage, String rawResponseFile, record Result(String status, BigDecimal weight, BigDecimal confidence,
String status, BigDecimal weight, Double confidence, String ocrRawText, String error) {}
java.util.List<?> candidates, String error) {}
} }
@@ -18,6 +18,7 @@ import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Service @Service
@@ -39,7 +40,7 @@ public class MeasurementImageService {
FileObjectEntity object = storeObject( FileObjectEntity object = storeObject(
tenantId, inboundId, operatorId, file, "INBOUND_MEASUREMENT_IMAGE"); tenantId, inboundId, operatorId, file, "INBOUND_MEASUREMENT_IMAGE");
return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(), 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, private FileObjectEntity storeObject(long tenantId, long inboundId, long operatorId,
@@ -116,7 +117,8 @@ public class MeasurementImageService {
} }
var result = response.data(); var result = response.data();
return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(), 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) { } catch (FeignException exception) {
return failed(object, "OCR服务不可用,可稍后重试或人工录入"); return failed(object, "OCR服务不可用,可稍后重试或人工录入");
} }
@@ -124,12 +126,19 @@ public class MeasurementImageService {
private UploadResult failed(FileObjectEntity object, String message) { private UploadResult failed(FileObjectEntity object, String message) {
return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(), 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, public record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes,
String recognitionStatus, BigDecimal weightValue, String weightUnit, 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) {} public record Download(String fileName, String contentType, byte[] content) {}
private record StoredImageMultipartFile(String originalFilename, String contentType, byte[] bytes) private record StoredImageMultipartFile(String originalFilename, String contentType, byte[] bytes)
@@ -29,7 +29,7 @@ class InternalMeasurementImageControllerTest {
var file = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE, var file = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE,
new byte[]{1, 2, 3}); new byte[]{1, 2, 3});
var result = new MeasurementImageService.UploadResult(41L, "measure.png", 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); when(images.uploadMeasurementEvidence(11L, 21L, 31L, file)).thenReturn(result);
var response = controller.upload("internal-token", 31L, 11L, 21L, file); var response = controller.upload("internal-token", 31L, 11L, 21L, file);
@@ -1,5 +1,6 @@
package com.baiyue.file.service; package com.baiyue.file.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.file.client.WeightOcrClient; import com.baiyue.file.client.WeightOcrClient;
import com.baiyue.file.entity.FileObjectEntity; import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper; import com.baiyue.file.mapper.FileObjectMapper;
@@ -18,6 +19,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import java.nio.file.Path; import java.nio.file.Path;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -91,4 +93,27 @@ class MeasurementImageServiceEvidenceTest {
.isInstanceOf(com.baiyue.common.core.exception.BizException.class); .isInstanceOf(com.baiyue.common.core.exception.BizException.class);
verifyNoInteractions(storage); 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");
}
} }
@@ -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;
}
@@ -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<PriceWarehouseMappingEntity> {}
@@ -7,11 +7,13 @@ import com.baiyue.firstleg.entity.PriceBookChannelEntity;
import com.baiyue.firstleg.entity.PriceBookVersionEntity; import com.baiyue.firstleg.entity.PriceBookVersionEntity;
import com.baiyue.firstleg.entity.PriceChannelCatalogEntity; import com.baiyue.firstleg.entity.PriceChannelCatalogEntity;
import com.baiyue.firstleg.entity.PriceOriginAliasEntity; import com.baiyue.firstleg.entity.PriceOriginAliasEntity;
import com.baiyue.firstleg.entity.PriceWarehouseMappingEntity;
import com.baiyue.firstleg.mapper.CarrierFbaRateMapper; import com.baiyue.firstleg.mapper.CarrierFbaRateMapper;
import com.baiyue.firstleg.mapper.PriceBookChannelMapper; import com.baiyue.firstleg.mapper.PriceBookChannelMapper;
import com.baiyue.firstleg.mapper.PriceBookVersionMapper; import com.baiyue.firstleg.mapper.PriceBookVersionMapper;
import com.baiyue.firstleg.mapper.PriceChannelCatalogMapper; import com.baiyue.firstleg.mapper.PriceChannelCatalogMapper;
import com.baiyue.firstleg.mapper.PriceOriginAliasMapper; import com.baiyue.firstleg.mapper.PriceOriginAliasMapper;
import com.baiyue.firstleg.mapper.PriceWarehouseMappingMapper;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -25,6 +27,7 @@ public class PriceBookRepository {
@Resource private CarrierFbaRateMapper rates; @Resource private CarrierFbaRateMapper rates;
@Resource private PriceChannelCatalogMapper catalog; @Resource private PriceChannelCatalogMapper catalog;
@Resource private PriceOriginAliasMapper aliases; @Resource private PriceOriginAliasMapper aliases;
@Resource private PriceWarehouseMappingMapper warehouseMappings;
public int insertVersion(PriceBookVersionEntity value) { return versions.insert(value); } public int insertVersion(PriceBookVersionEntity value) { return versions.insert(value); }
public int updateVersion(PriceBookVersionEntity value) { public int updateVersion(PriceBookVersionEntity value) {
@@ -109,6 +112,11 @@ public class PriceBookRepository {
return aliases.selectList(new LambdaQueryWrapper<PriceOriginAliasEntity>() return aliases.selectList(new LambdaQueryWrapper<PriceOriginAliasEntity>()
.eq(PriceOriginAliasEntity::getStatus, "ACTIVE")); .eq(PriceOriginAliasEntity::getStatus, "ACTIVE"));
} }
public List<PriceWarehouseMappingEntity> activeWarehouseMappings() {
return warehouseMappings.selectList(new LambdaQueryWrapper<PriceWarehouseMappingEntity>()
.eq(PriceWarehouseMappingEntity::getStatus, "ACTIVE")
.orderByAsc(PriceWarehouseMappingEntity::getId));
}
public void supersede(PriceBookVersionEntity old, LocalDateTime at, long operatorId) { public void supersede(PriceBookVersionEntity old, LocalDateTime at, long operatorId) {
old.setStatus("SUPERSEDED"); old.setSupersededAt(at); old.setUpdatedBy(operatorId); old.setUpdatedAt(at); old.setStatus("SUPERSEDED"); old.setSupersededAt(at); old.setUpdatedBy(operatorId); old.setUpdatedAt(at);
updateVersion(old); updateVersion(old);
@@ -16,6 +16,7 @@ import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.PriceBookChannelEntity; import com.baiyue.firstleg.entity.PriceBookChannelEntity;
import com.baiyue.firstleg.entity.PriceBookVersionEntity; import com.baiyue.firstleg.entity.PriceBookVersionEntity;
import com.baiyue.firstleg.entity.PriceChannelCatalogEntity; 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.PriceWorkbookParseResult;
import com.baiyue.firstleg.pricing.importer.PriceWorkbookParser; import com.baiyue.firstleg.pricing.importer.PriceWorkbookParser;
import com.baiyue.firstleg.repository.PriceBookRepository; import com.baiyue.firstleg.repository.PriceBookRepository;
@@ -262,7 +263,37 @@ public class PlatformPriceBookService {
List<PriceBookDtos.ParseIssue> issues = parsed.issues().stream().map(value -> issue( List<PriceBookDtos.ParseIssue> issues = parsed.issues().stream().map(value -> issue(
value.severity() == PriceWorkbookParseResult.Severity.ERROR ? "BLOCKING" : "WARNING", value.severity() == PriceWorkbookParseResult.Severity.ERROR ? "BLOCKING" : "WARNING",
value.code(), value.message(), value.sheetName(), value.rowNumber(), null)).toList(); 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<PriceBookDtos.ParsedRate> applyInitializedWarehouseMappings(List<PriceBookDtos.ParsedRate> rates) {
Map<String, PriceWarehouseMappingEntity> origins = new HashMap<>();
Map<String, PriceWarehouseMappingEntity> 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, private PriceBookDtos.ParsedRate parsedRate(String channelCode, PriceModel model,
@@ -411,6 +411,21 @@ CREATE TABLE IF NOT EXISTS fl_price_origin_alias (
KEY idx_price_origin_alias_name (alias_name, status) KEY idx_price_origin_alias_name (alias_name, status)
) ENGINE=InnoDB COMMENT='价格表起运仓名称别名映射'; ) 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 ( CREATE TABLE IF NOT EXISTS fl_price_book_version (
id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID', id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID',
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共价格作用域固定为0', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共价格作用域固定为0',
@@ -494,9 +509,21 @@ VALUES
(3, 'CN-YIWU-01', '义乌', 'ACTIVE'), (3, 'CN-YIWU-01', '义乌', 'ACTIVE'),
(4, 'CN-DONGGUAN-01', '华南仓(深圳)', 'ACTIVE'), (4, 'CN-DONGGUAN-01', '华南仓(深圳)', 'ACTIVE'),
(5, '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); 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 ( CREATE TABLE IF NOT EXISTS fl_inbound_fee (
id BIGINT UNSIGNED NOT NULL COMMENT '入库费用ID', id BIGINT UNSIGNED NOT NULL COMMENT '入库费用ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
@@ -28,6 +28,8 @@ class PriceBookSqlContractTest {
assertThat(schema) assertThat(schema)
.contains("CREATE TABLE IF NOT EXISTS fl_price_channel_catalog") .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_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_version")
.contains("CREATE TABLE IF NOT EXISTS fl_price_book_channel") .contains("CREATE TABLE IF NOT EXISTS fl_price_book_channel")
.contains("price_book_version_id") .contains("price_book_version_id")
@@ -40,6 +42,11 @@ class PriceBookSqlContractTest {
.contains("destination_name VARCHAR(128)") .contains("destination_name VARCHAR(128)")
.contains("parse_issues_json JSON NULL") .contains("parse_issues_json JSON NULL")
.contains("DEFAULT 'PENDING_CONFIRMATION'"); .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) assertThat(channelTable)
.contains("created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID'") .contains("created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID'")
.contains("updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID'") .contains("updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID'")
@@ -14,6 +14,7 @@ import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.PriceBookChannelEntity; import com.baiyue.firstleg.entity.PriceBookChannelEntity;
import com.baiyue.firstleg.entity.PriceBookVersionEntity; import com.baiyue.firstleg.entity.PriceBookVersionEntity;
import com.baiyue.firstleg.entity.PriceChannelCatalogEntity; import com.baiyue.firstleg.entity.PriceChannelCatalogEntity;
import com.baiyue.firstleg.entity.PriceWarehouseMappingEntity;
import com.baiyue.firstleg.repository.PriceBookRepository; import com.baiyue.firstleg.repository.PriceBookRepository;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -230,6 +231,29 @@ class PlatformPriceBookServiceTest {
.doesNotContain("DUPLICATE_RATE"); .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<PriceBookDtos.ParsedRate> 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 @Test
void duplicatePublishIsRejectedAfterPublishLock() { void duplicatePublishIsRejectedAfterPublishLock() {
when(repository.findVersion(31L)).thenReturn(version(31L, PriceBookStatus.ACTIVE)); when(repository.findVersion(31L)).thenReturn(version(31L, PriceBookStatus.ACTIVE));
@@ -300,6 +324,18 @@ class PlatformPriceBookServiceTest {
List.of(new PriceBookConfirmationRequest.OriginMapping(code, id)), List.of()); 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() { private void executeTransactionsImmediately() {
when(transactions.execute(any())).thenAnswer(invocation -> { when(transactions.execute(any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@@ -1,16 +1,22 @@
package com.baiyue.integration.dto.ocr; package com.baiyue.integration.dto.ocr;
import java.math.BigDecimal;
/** /**
* 百度 OCR 返回的文字候选及其识别框。 * 百度 OCR 返回的文字候选及其识别框。
*/ */
public record OcrCandidate( public record OcrCandidate(
String text, String text,
Double confidence, BigDecimal confidence,
Integer left, Integer left,
Integer top, Integer top,
Integer width, Integer width,
Integer height Integer height
) { ) {
public OcrCandidate {
confidence = OcrConfidence.normalize(confidence);
}
public long area() { public long area() {
if (width == null || height == null || width <= 0 || height <= 0) { if (width == null || height == null || width <= 0 || height <= 0) {
return 0; return 0;
@@ -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);
}
}
@@ -12,10 +12,15 @@ public record WeightOcrImageResult(
String rawResponseFile, String rawResponseFile,
Status status, Status status,
BigDecimal weight, BigDecimal weight,
Double confidence, BigDecimal confidence,
List<OcrCandidate> candidates, List<OcrCandidate> candidates,
String ocrRawText,
String error String error
) { ) {
public WeightOcrImageResult {
confidence = OcrConfidence.normalize(confidence);
}
public enum Status { public enum Status {
RECOGNIZED, RECOGNIZED,
NO_WEIGHT_FOUND, NO_WEIGHT_FOUND,
@@ -12,6 +12,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.io.IOException; import java.io.IOException;
import java.math.BigDecimal;
import java.net.URI; import java.net.URI;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.net.http.HttpClient; import java.net.http.HttpClient;
@@ -163,7 +164,7 @@ public class BaiduMeterClient {
JsonNode location = item.path("location"); JsonNode location = item.path("location");
candidates.add(new OcrCandidate( candidates.add(new OcrCandidate(
item.path("words").asText(), item.path("words").asText(),
nullableDouble(probability.get("average")), nullableDecimal(probability.get("average")),
nullableInt(location.get("left")), nullableInt(location.get("left")),
nullableInt(location.get("top")), nullableInt(location.get("top")),
nullableInt(location.get("width")), nullableInt(location.get("width")),
@@ -195,8 +196,8 @@ public class BaiduMeterClient {
return URLEncoder.encode(value, StandardCharsets.UTF_8); return URLEncoder.encode(value, StandardCharsets.UTF_8);
} }
private static Double nullableDouble(JsonNode node) { private static BigDecimal nullableDecimal(JsonNode node) {
return node == null || !node.isNumber() ? null : node.doubleValue(); return node == null || !node.isNumber() ? null : node.decimalValue();
} }
private static Integer nullableInt(JsonNode node) { private static Integer nullableInt(JsonNode node) {
@@ -25,7 +25,8 @@ public class WeightCandidateSelector {
.flatMap(Optional::stream) .flatMap(Optional::stream)
.max(Comparator .max(Comparator
.comparingLong((SelectedWeight selected) -> selected.candidate().area()) .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<SelectedWeight> toSelectedWeight(OcrCandidate candidate) { private Optional<SelectedWeight> toSelectedWeight(OcrCandidate candidate) {
@@ -55,6 +56,6 @@ public class WeightCandidateSelector {
.replace('-', '-'); .replace('-', '-');
} }
public record SelectedWeight(BigDecimal weight, Double confidence, OcrCandidate candidate) { public record SelectedWeight(BigDecimal weight, BigDecimal confidence, OcrCandidate candidate) {
} }
} }
@@ -34,6 +34,7 @@ import java.util.stream.Stream;
@Service @Service
public class WeightOcrBatchService { public class WeightOcrBatchService {
private static final Set<String> SUPPORTED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "bmp"); private static final Set<String> SUPPORTED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "bmp");
private static final int RAW_TEXT_MAX_LENGTH = 1000;
@Resource private IntegrationProperties properties; @Resource private IntegrationProperties properties;
@Resource private WeightOcrStoragePolicy storagePolicy; @Resource private WeightOcrStoragePolicy storagePolicy;
@@ -113,7 +114,7 @@ public class WeightOcrBatchService {
return new WeightOcrImageResult( return new WeightOcrImageResult(
source.toString(), outputRelative(paths, cropped), outputRelative(paths, rawResponse), source.toString(), outputRelative(paths, cropped), outputRelative(paths, rawResponse),
Status.RECOGNIZED, selected.weight(), selected.confidence(), Status.RECOGNIZED, selected.weight(), selected.confidence(),
response.candidates(), null); response.candidates(), summarizeCandidates(response.candidates()), null);
} }
private WeightOcrImageResult noWeight(Path source, BatchPaths paths, Path cropped, private WeightOcrImageResult noWeight(Path source, BatchPaths paths, Path cropped,
@@ -121,6 +122,7 @@ public class WeightOcrBatchService {
return new WeightOcrImageResult( return new WeightOcrImageResult(
source.toString(), outputRelative(paths, cropped), outputRelative(paths, rawResponse), source.toString(), outputRelative(paths, cropped), outputRelative(paths, rawResponse),
Status.NO_WEIGHT_FOUND, null, null, response.candidates(), Status.NO_WEIGHT_FOUND, null, null, response.candidates(),
summarizeCandidates(response.candidates()),
"百度响应中没有符合重量格式的候选值"); "百度响应中没有符合重量格式的候选值");
} }
@@ -129,7 +131,33 @@ public class WeightOcrBatchService {
return new WeightOcrImageResult( return new WeightOcrImageResult(
source.toString(), Files.exists(cropped) ? outputRelative(paths, cropped) : null, source.toString(), Files.exists(cropped) ? outputRelative(paths, cropped) : null,
Files.exists(rawResponse) ? outputRelative(paths, rawResponse) : 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<com.baiyue.integration.dto.ocr.OcrCandidate> 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, private WeightOcrBatchResult buildResult(String batchId, Instant startedAt, BatchPaths paths,
@@ -56,7 +56,9 @@ public class WeightOcrSingleImageService {
if (batch.results() == null || batch.results().size() != 1) { if (batch.results() == null || batch.results().size() != 1) {
throw new BizException(ResultCode.INTERNAL_ERROR, "OCR单图识别结果数量异常"); 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 { } finally {
deleteRequestDirectory(requestDirectory); deleteRequestDirectory(requestDirectory);
} }
@@ -4,6 +4,7 @@ import com.baiyue.integration.dto.ocr.OcrCandidate;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List; import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -15,21 +16,31 @@ class WeightCandidateSelectorTest {
@Test @Test
void selectsLargestNumericCandidateAndNormalizesDecimalSeparator() { void selectsLargestNumericCandidateAndNormalizesDecimalSeparator() {
List<OcrCandidate> candidates = List.of( List<OcrCandidate> candidates = List.of(
new OcrCandidate("10:38", 0.99, 10, 10, 40, 10), new OcrCandidate("10:38", new BigDecimal("0.99"), 10, 10, 40, 10),
new OcrCandidate(" 12,35 ", 0.97, 20, 30, 120, 60), new OcrCandidate(" 12,35 ", new BigDecimal("0.97"), 20, 30, 120, 60),
new OcrCandidate("29", 0.99, 160, 10, 20, 10), new OcrCandidate("29", new BigDecimal("0.99"), 160, 10, 20, 10),
new OcrCandidate("kg", 0.99, 150, 70, 30, 15)); new OcrCandidate("kg", new BigDecimal("0.99"), 150, 70, 30, 15));
WeightCandidateSelector.SelectedWeight selected = selector.select(candidates).orElseThrow(); WeightCandidateSelector.SelectedWeight selected = selector.select(candidates).orElseThrow();
assertThat(selected.weight()).isEqualByComparingTo("12.35"); assertThat(selected.weight()).isEqualByComparingTo("12.35");
assertThat(selected.confidence()).isEqualTo(0.97); assertThat(selected.confidence()).isEqualByComparingTo("0.97000");
} }
@Test @Test
void rejectsNonNumericCandidates() { void rejectsNonNumericCandidates() {
assertThat(selector.select(List.of( assertThat(selector.select(List.of(
new OcrCandidate("kg", 0.99, 0, 0, 20, 10), new OcrCandidate("kg", new BigDecimal("0.99"), 0, 0, 20, 10),
new OcrCandidate("10:38", 0.99, 0, 0, 20, 10)))).isEmpty(); 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");
} }
} }
@@ -19,6 +19,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
import java.awt.Color; import java.awt.Color;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.math.BigDecimal;
import java.net.URI; import java.net.URI;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -56,13 +57,17 @@ class WeightOcrBatchServiceTest {
when(properties.baiduOcr()).thenReturn(properties(inputRoot, outputRoot).baiduOcr()); when(properties.baiduOcr()).thenReturn(properties(inputRoot, outputRoot).baiduOcr());
when(client.recognize(any())).thenReturn(new BaiduMeterClient.MeterResponse( when(client.recognize(any())).thenReturn(new BaiduMeterClient.MeterResponse(
"{\"words_result\":[]}", "{\"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)); var result = service.recognize(new WeightOcrBatchRequest("batch-a", null, null, null, false));
assertThat(result.total()).isEqualTo(1); assertThat(result.total()).isEqualTo(1);
assertThat(result.recognized()).isEqualTo(1); assertThat(result.recognized()).isEqualTo(1);
assertThat(result.results().get(0).status()).isEqualTo(Status.RECOGNIZED); 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()); Path batchOutput = outputRoot.resolve(result.outputDirectory());
assertThat(batchOutput.resolve("cropped/weight.jpg")).isRegularFile(); assertThat(batchOutput.resolve("cropped/weight.jpg")).isRegularFile();
assertThat(batchOutput.resolve("raw/weight.json")).isRegularFile(); assertThat(batchOutput.resolve("raw/weight.json")).isRegularFile();
@@ -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();
}
}