diff --git a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java index fad98d34f..55d31cb86 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java @@ -40,6 +40,11 @@ public class InternalAccountController { @Valid @RequestBody CreateAccountRequest request) { verify(token); return ApiResponse.success(service.create(request)); } + @GetMapping("/availability") + public ApiResponse availability(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @RequestParam String username) { + verify(token); return ApiResponse.success(service.usernameAvailable(username)); + } @GetMapping("/{id}") public ApiResponse summary(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, @PathVariable Long id) { diff --git a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java index 5c4252efc..9305f96a7 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java @@ -21,6 +21,10 @@ public class AccountManagementService { @Resource private PasswordEncoder passwordEncoder; @Resource private TokenSessionStore tokenSessionStore; + public boolean usernameAvailable(String username) { + return username != null && !username.isBlank() && !repository.existsByUsername(username.trim()); + } + @Transactional @BusinessAudit(event = "PASSWORD_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "修改本人密码", captureAfter = false) public void changePassword(Long accountId, ChangePasswordRequest request) { diff --git a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java index a486c65c7..3cd148e1e 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java @@ -75,6 +75,13 @@ class AccountManagementServiceTest { assertNotEquals("Password1", captor.getValue().getPasswordHash()); } + @Test void usernameAvailabilityUsesTheAuthAccountRepositoryAsTheFactSource() { + when(repository.existsByUsername("13800138000")).thenReturn(true); + + assertFalse(service.usernameAvailable("13800138000")); + assertTrue(service.usernameAvailable("13900139000")); + } + @Test void createDisabledAccountAndSummaryExposeLastLoginAndScope() { when(repository.findById(20L)).thenReturn(null); when(repository.existsByUsername("merchant.user")).thenReturn(false); diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/cn/firstleg-service.yml index 4e086ad41..0771aee5c 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/cn/firstleg-service.yml @@ -18,6 +18,15 @@ firstleg: max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m} nonce-ttl: ${MEASUREMENT_OPENAPI_NONCE_TTL:10m} apps: + # 墨家设备群共用一组固定 AppKey/Secret;每台机器用 data.deviceCode 区分。 + mojia: + enabled: ${MOJIA_MEASUREMENT_ENABLED:false} + secret: ${MOJIA_MEASUREMENT_SECRET:} + tenant-id: ${MOJIA_MEASUREMENT_TENANT_ID:0} + subject-id: ${MOJIA_MEASUREMENT_SUBJECT_ID:0} + warehouse-bindings: + CN-YW-01: ${MOJIA_MEASUREMENT_WAREHOUSE_ID:0} + # 历史通用应用配置,保留兼容;新接入请使用 mojia。 device-provider: enabled: ${MEASUREMENT_DEVICE_PROVIDER_ENABLED:false} secret: ${MEASUREMENT_DEVICE_PROVIDER_SECRET:} diff --git a/config/nacos/local/firstleg-service.yml b/config/nacos/local/firstleg-service.yml index 3ecdeaec1..eb233e21c 100644 --- a/config/nacos/local/firstleg-service.yml +++ b/config/nacos/local/firstleg-service.yml @@ -17,6 +17,15 @@ firstleg: max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m} nonce-ttl: ${MEASUREMENT_OPENAPI_NONCE_TTL:10m} apps: + # 墨家设备群共用一组固定 AppKey/Secret;每台机器用 data.deviceCode 区分。 + mojia: + enabled: ${MOJIA_MEASUREMENT_ENABLED:false} + secret: ${MOJIA_MEASUREMENT_SECRET:} + tenant-id: ${MOJIA_MEASUREMENT_TENANT_ID:0} + subject-id: ${MOJIA_MEASUREMENT_SUBJECT_ID:0} + warehouse-bindings: + CN-YW-01: ${MOJIA_MEASUREMENT_WAREHOUSE_ID:0} + # 历史通用应用配置,保留兼容;新接入请使用 mojia。 device-provider: enabled: ${MEASUREMENT_DEVICE_PROVIDER_ENABLED:false} secret: ${MEASUREMENT_DEVICE_PROVIDER_SECRET:} diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 406fc1eb1..59d5e1f5f 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -5,6 +5,7 @@ - 固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 - 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 +- 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。 - 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。 - 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`,不会写入固定密码账号、演示仓库、演示快递公司或演示订单;正式平台公共仓库主数据已内联到固定入口。 - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 @@ -115,6 +116,12 @@ SELECT COUNT(*) = 8 AS scheduler_columns_ready - 新增 `external_request_hash`、`inbound_detail_id`、`external_detail_no` 和第三方明细唯一索引,用于一条回传绑定一条订单货物明细及一张文件中心图片。 - 部署 Firstleg 新版本前必须完整执行固定入口,并确认末尾总体验证为 `PASS`。 +## 稳定箱号与单箱当前测量 + +- 固定入口已包含 `fl_inbound_carton_label`,首次打印时按订单货物明细冻结每个实际箱的 `box_code` 和箱下标;重复打印、部分补打及设备测量均复用该映射。 +- `fl_inbound_measurement_item` 已补齐 `box_code`、`machine_no`、`device_measured_at`、`updated_at`,并通过 `uk_measure_box_code (tenant_id, box_code)` 保证同一客户下每个箱号只有一条当前测量记录。 +- 部署 Firstleg 新版本前必须完整执行固定入口。末尾会强校验映射表、四个测量字段及三个唯一约束,任一缺失均不会返回 `PASS`。 + ## 当前主体字段语义核验 - 业务单租户阶段,业务表中的 `tenant_id > 0` 是客户 ID 和客户隔离键,不表示上级租户;费用项目和入库费用明细沿用该物理字段名。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 97de08871..097cef974 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -432,6 +432,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID快照', carrier_name VARCHAR(128) NULL COMMENT '船司名称快照', tax_mode VARCHAR(24) NULL COMMENT '买单或退税', + pricing_tax_mode VARCHAR(16) NULL COMMENT '报价包税维度:TAX_INCLUDED或TAX_EXCLUDED;独立于tax_mode', container_mode VARCHAR(24) NULL COMMENT '整柜或散货', customs_inspection TINYINT(1) NULL COMMENT '是否商检', destination_type VARCHAR(24) NULL COMMENT 'FBA/OVERSEAS/PRIVATE', @@ -512,6 +513,12 @@ CREATE TABLE IF NOT EXISTS ord_order_cargo ( product_usage VARCHAR(128) NULL COMMENT '用途', model VARCHAR(64) NULL COMMENT '型号', brand VARCHAR(64) NULL COMMENT '品牌', + product_length_cm DECIMAL(12,2) NULL COMMENT '产品长度(厘米)', + product_width_cm DECIMAL(12,2) NULL COMMENT '产品宽度(厘米)', + product_height_cm DECIMAL(12,2) NULL COMMENT '产品高度(厘米)', + shipment_id VARCHAR(64) NULL COMMENT '货物级 Amazon Shipment ID', + reference_id VARCHAR(64) NULL COMMENT '货物级 Amazon Reference ID', + product_image_file_id BIGINT UNSIGNED NULL COMMENT '文件中心产品图片逻辑ID', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', @@ -1545,6 +1552,20 @@ CALL phase1_add_column('ord_order_cargo', 'material', "VARCHAR(128) NULL COMMENT CALL phase1_add_column('ord_order_cargo', 'product_usage', "VARCHAR(128) NULL COMMENT 'Usage'"); CALL phase1_add_column('ord_order_cargo', 'model', "VARCHAR(64) NULL COMMENT 'Model'"); CALL phase1_add_column('ord_order_cargo', 'brand', "VARCHAR(64) NULL COMMENT 'Brand'"); +CALL phase1_add_column('ord_order_cargo', 'product_length_cm', "DECIMAL(12,2) NULL COMMENT 'Product length in centimeters'"); +CALL phase1_add_column('ord_order_cargo', 'product_width_cm', "DECIMAL(12,2) NULL COMMENT 'Product width in centimeters'"); +CALL phase1_add_column('ord_order_cargo', 'product_height_cm', "DECIMAL(12,2) NULL COMMENT 'Product height in centimeters'"); +CALL phase1_add_column('ord_order_cargo', 'shipment_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Shipment ID'"); +CALL phase1_add_column('ord_order_cargo', 'reference_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Reference ID'"); +CALL phase1_add_column('ord_order_cargo', 'product_image_file_id', "BIGINT UNSIGNED NULL COMMENT 'Product image logical file ID'"); + +UPDATE ord_order_cargo cargo +JOIN ord_order orders ON orders.id = cargo.order_id AND orders.tenant_id = cargo.tenant_id +SET cargo.shipment_id = COALESCE(NULLIF(cargo.shipment_id, ''), orders.shipment_id), + cargo.reference_id = COALESCE(NULLIF(cargo.reference_id, ''), orders.reference_id) +WHERE orders.destination_type = 'FBA' + AND ((cargo.shipment_id IS NULL OR cargo.shipment_id = '') + OR (cargo.reference_id IS NULL OR cargo.reference_id = '')); DROP PROCEDURE phase1_add_column; @@ -2276,6 +2297,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( carrier_id BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID', carrier_name VARCHAR(128) NULL COMMENT '计费船司名称快照', tax_mode VARCHAR(24) NULL COMMENT '买单或退税模式', + pricing_tax_mode VARCHAR(16) NULL COMMENT '报价包税维度:TAX_INCLUDED/TAX_EXCLUDED', container_mode VARCHAR(24) NULL COMMENT '整柜或散货模式', customs_inspection TINYINT(1) NULL COMMENT '是否商检', destination_type VARCHAR(24) NULL COMMENT '目的仓类型', @@ -2324,7 +2346,19 @@ UPDATE baiyue_order.ord_order o LEFT JOIN baiyue_firstleg.fl_inbound_order i ON i.tenant_id = o.tenant_id AND i.order_id = o.id AND i.deleted = 0 SET o.inbound_code = COALESCE(i.inbound_code, CONCAT('RK-', o.id)) - WHERE o.deleted = 0 AND o.status <> 'DRAFT' AND o.inbound_code IS NULL; + WHERE o.deleted = 0 + AND o.status <> 'DRAFT' + AND (o.submitted_at IS NOT NULL + OR o.status IN ('SUBMITTED', 'APPROVED', 'ACCEPTED', 'PROCESSING', 'COMPLETED')) + AND o.inbound_code IS NULL; + +-- 从未提交的草稿或由草稿直接取消的订单不应拥有入仓码。 +UPDATE baiyue_order.ord_order + SET inbound_code = NULL + WHERE deleted = 0 + AND inbound_code IS NOT NULL + AND (status = 'DRAFT' + OR (status IN ('REJECTED', 'CANCELLED') AND submitted_at IS NULL)); CREATE TABLE IF NOT EXISTS fl_inbound_pickup_snapshot ( id BIGINT UNSIGNED NOT NULL COMMENT '提货信息快照ID', @@ -2426,6 +2460,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item ( measurement_id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID', inbound_detail_id BIGINT UNSIGNED NULL COMMENT '绑定的订单货物明细ID,平台手工明细可为空', external_detail_no VARCHAR(128) NULL COMMENT '第三方测量明细唯一号,平台手工明细可为空', + box_code VARCHAR(128) NULL COMMENT '柏悦唛头箱号;设备单箱测量时同一客户内唯一', item_no VARCHAR(32) NOT NULL COMMENT '批次内明细号', box_specification VARCHAR(64) NOT NULL COMMENT '箱规标识', package_count INT UNSIGNED NOT NULL COMMENT '相同箱规箱数', @@ -2442,18 +2477,47 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item ( volume_weight_kg DECIMAL(18,3) NOT NULL COMMENT '本箱规材积重kg', calculation_rule_version VARCHAR(32) NOT NULL COMMENT '体积计算和舍入规则版本', source_image_file_id BIGINT UNSIGNED NULL COMMENT '测量设备图片文件ID', + machine_no VARCHAR(64) NULL COMMENT '本次测量设备编号', + device_measured_at DATETIME(3) NULL COMMENT '本次设备测量时间', over_limit_warning VARCHAR(500) NULL COMMENT '超线提醒快照', outer_length_cm DECIMAL(12,3) NULL COMMENT '外箱规长度cm', outer_width_cm DECIMAL(12,3) NULL COMMENT '外箱规宽度cm', outer_height_cm DECIMAL(12,3) NULL COMMENT '外箱规高度cm', remark VARCHAR(500) NULL COMMENT '测量明细备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', PRIMARY KEY (id), UNIQUE KEY uk_measure_item_no (tenant_id, measurement_id, item_no), UNIQUE KEY uk_measure_external_detail (tenant_id, inbound_detail_id, external_detail_no), + UNIQUE KEY uk_measure_box_code (tenant_id, box_code), CONSTRAINT fk_measure_item_batch FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id) ) ENGINE=InnoDB COMMENT='入库长宽高和箱数测量明细'; +CREATE TABLE IF NOT EXISTS fl_inbound_carton_label ( + id BIGINT UNSIGNED NOT NULL COMMENT '实际箱唛头映射ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '当前客户隔离ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID快照', + inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID,不建立跨库外键', + cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照', + cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', + detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照', + inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', + box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', + total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数', + carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标', + box_specification VARCHAR(255) NOT NULL COMMENT '订单箱规或货物明细描述快照', + length_cm DECIMAL(12,3) NULL COMMENT '下单箱规长度cm快照', + width_cm DECIMAL(12,3) NULL COMMENT '下单箱规宽度cm快照', + height_cm DECIMAL(12,3) NULL COMMENT '下单箱规高度cm快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次分配时间', + PRIMARY KEY (id), + UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code), + UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, carton_index), + KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id), + CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射'; + CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', @@ -2560,22 +2624,34 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule ( CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate ( id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台价格作用域ID,船司价格固定为0', + price_book_version_id BIGINT UNSIGNED NULL COMMENT '整表价格版本ID;历史兼容价格可为空', rate_code VARCHAR(50) NOT NULL COMMENT '平台公共价格编码', rate_version INT UNSIGNED NOT NULL COMMENT '价格版本号', - origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓逻辑ID', + origin_warehouse_id BIGINT UNSIGNED NULL COMMENT '确认前可为空;确认后绑定平台公共起运仓逻辑ID', + origin_warehouse_code VARCHAR(64) NULL COMMENT '起运仓稳定编码', origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照', business_type VARCHAR(32) NOT NULL COMMENT '业务或计费类型', channel_code VARCHAR(64) NULL COMMENT '渠道编码', + price_model VARCHAR(32) NOT NULL DEFAULT 'FBA_WAREHOUSE_RATE' COMMENT '价格模型', carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID', - carrier_name VARCHAR(128) NOT NULL COMMENT '船司名称快照', - destination_code VARCHAR(32) NOT NULL COMMENT 'FBA目的仓编码', + carrier_name VARCHAR(128) NULL COMMENT '历史承运商名称;渠道价格可为空', + destination_type VARCHAR(16) NULL COMMENT '目的地类型:FBA或OVERSEAS;REGION_WEIGHT_TIER区域阶梯行为空', + destination_code VARCHAR(32) NULL COMMENT 'FBA目的仓编码;区域阶梯模型为空', + destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '平台海外仓稳定ID;不存环境专属ID', + destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照', + region_code VARCHAR(16) NULL COMMENT '区域阶梯模型区域编码', + pricing_tax_mode VARCHAR(16) NULL COMMENT '报价包税维度;独立于订单tax_mode', + weight_from_kg DECIMAL(18,6) NULL COMMENT '区域阶梯起始计费重量KG', charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位:KG或CBM', unit_price DECIMAL(18,6) NOT NULL COMMENT '基础单价', currency CHAR(3) NOT NULL COMMENT '币种', additional_fee DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '固定附加费', + source_sheet_name VARCHAR(128) NULL COMMENT '原始Excel Sheet', + source_row_number INT UNSIGNED NULL COMMENT '原始Excel行号', + source_destination_text VARCHAR(1000) NULL COMMENT '原始组合仓库或区域文本', effective_from DATETIME(3) NOT NULL COMMENT '生效开始时间', effective_to DATETIME(3) NULL COMMENT '生效结束时间', - status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '价格状态', + status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '价格状态:PENDING_CONFIRMATION、ACTIVE或SUPERSEDED', remark VARCHAR(500) NULL COMMENT '价格备注', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -2585,39 +2661,51 @@ CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_fba_rate_version (tenant_id, rate_code, rate_version), - KEY idx_fba_rate_match (tenant_id, origin_warehouse_id, business_type, channel_code, carrier_id, destination_code, charge_unit, status, effective_from, effective_to) + KEY idx_fba_rate_match (tenant_id, origin_warehouse_id, business_type, channel_code, carrier_id, destination_type, destination_code, destination_warehouse_id, charge_unit, status, effective_from, effective_to), + KEY idx_fba_rate_book (price_book_version_id, channel_code, origin_warehouse_id, price_model, status), + KEY idx_fba_rate_destination (price_book_version_id, channel_code, origin_warehouse_id, destination_type, destination_warehouse_id, destination_code), + KEY idx_fba_rate_region (price_book_version_id, channel_code, origin_warehouse_id, region_code, weight_from_kg) ) ENGINE=InnoDB COMMENT='船司FBA目的仓价格版本'; +ALTER TABLE fl_carrier_fba_rate + MODIFY COLUMN status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' + COMMENT '价格状态:PENDING_CONFIRMATION、ACTIVE或SUPERSEDED'; + CREATE TABLE IF NOT EXISTS fl_inbound_fee ( id BIGINT UNSIGNED NOT NULL COMMENT '入库费用ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', fee_no VARCHAR(40) NOT NULL COMMENT '租户内唯一费用单号', fee_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用快照版本', - charge_rule_id BIGINT UNSIGNED NOT NULL COMMENT '客户计费规则ID', - charge_rule_version INT UNSIGNED NOT NULL COMMENT '客户规则版本快照', + charge_rule_id BIGINT UNSIGNED NULL COMMENT '历史客户计费规则ID;渠道价格可为空', + charge_rule_version INT UNSIGNED NULL COMMENT '历史客户规则版本快照', carrier_rate_id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID', carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照', - rule_code VARCHAR(50) NOT NULL COMMENT '客户规则编码快照', + price_book_version_id BIGINT UNSIGNED NULL COMMENT '整表价格版本ID快照', + rule_code VARCHAR(50) NULL COMMENT '历史客户规则编码快照', customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID快照', customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', business_type VARCHAR(32) NOT NULL COMMENT '业务类型快照', - tax_mode VARCHAR(24) NULL COMMENT '包税模式快照', - rounding_scale TINYINT UNSIGNED NOT NULL COMMENT '计费量保留位数快照', - rounding_mode VARCHAR(24) NOT NULL COMMENT '舍入方式快照', - rule_effective_from DATETIME(3) NOT NULL COMMENT '客户规则生效开始快照', + tax_mode VARCHAR(24) NULL COMMENT '订单买单或退税模式快照', + pricing_tax_mode VARCHAR(16) NULL COMMENT '报价包税维度快照', + rounding_scale TINYINT UNSIGNED NULL COMMENT '历史客户规则计费量保留位数快照', + rounding_mode VARCHAR(24) NULL COMMENT '历史客户规则舍入方式快照', + rule_effective_from DATETIME(3) NULL COMMENT '历史客户规则生效开始快照', rule_effective_to DATETIME(3) NULL COMMENT '客户规则生效结束快照', rate_code VARCHAR(50) NOT NULL COMMENT '船司价格编码快照', origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓ID快照', origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照', channel_code VARCHAR(64) NOT NULL COMMENT '渠道编码快照', - carrier_id BIGINT UNSIGNED NOT NULL COMMENT '船司ID快照', - carrier_name VARCHAR(128) NOT NULL COMMENT '船司名称快照', - destination_code VARCHAR(32) NOT NULL COMMENT 'FBA仓编码快照', + carrier_id BIGINT UNSIGNED NULL COMMENT '历史承运商ID;渠道价格可为空', + carrier_name VARCHAR(128) NULL COMMENT '历史承运商名称;渠道价格可为空', + destination_type VARCHAR(16) NULL COMMENT '目的地类型快照:FBA或OVERSEAS', + destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '海外仓或FBA目的仓ID快照', + destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照', + destination_code VARCHAR(32) NULL COMMENT 'FBA仓编码快照;海外仓可为空', rate_effective_from DATETIME(3) NOT NULL COMMENT '价格生效开始快照', rate_effective_to DATETIME(3) NULL COMMENT '价格生效结束快照', - charge_mode VARCHAR(16) NOT NULL COMMENT '计费口径:KG或CBM', - conversion_factor DECIMAL(18,6) NOT NULL COMMENT '使用的换算系数快照', + charge_mode VARCHAR(16) NULL COMMENT '计费口径:KG或CBM', + conversion_factor DECIMAL(18,6) NULL COMMENT '历史客户规则使用的换算系数快照', actual_weight_kg DECIMAL(18,3) NOT NULL COMMENT '试算时实际重量kg', actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3', converted_quantity DECIMAL(18,4) NOT NULL COMMENT '换算后的重量或体积', @@ -2837,6 +2925,8 @@ CALL phase2_measure_add_column('fl_inbound_measurement', 'version', CALL phase2_measure_add_column('fl_inbound_measurement_item', 'box_specification', 'VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''箱规标识'' AFTER item_no'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'box_code', + 'VARCHAR(128) NULL COMMENT ''柏悦唛头箱号;设备单箱测量时同一客户内唯一'' AFTER external_detail_no'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'girth_cm', 'DECIMAL(12,3) NOT NULL DEFAULT 0 COMMENT ''周长cm,规则为长+2*(宽+高)'' AFTER height_cm'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'unit_weight_kg', @@ -2851,12 +2941,33 @@ CALL phase2_measure_add_column('fl_inbound_measurement_item', 'volume_weight_kg' 'DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''本箱规材积重kg'' AFTER total_volume_cbm'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'over_limit_warning', 'VARCHAR(500) NULL COMMENT ''超线提醒快照'' AFTER source_image_file_id'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'machine_no', + 'VARCHAR(64) NULL COMMENT ''本次测量设备编号'' AFTER source_image_file_id'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'device_measured_at', + 'DATETIME(3) NULL COMMENT ''本次设备测量时间'' AFTER machine_no'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_length_cm', 'DECIMAL(12,3) NULL COMMENT ''外箱规长度cm'' AFTER over_limit_warning'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_width_cm', 'DECIMAL(12,3) NULL COMMENT ''外箱规宽度cm'' AFTER outer_length_cm'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_height_cm', 'DECIMAL(12,3) NULL COMMENT ''外箱规高度cm'' AFTER outer_width_cm'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'updated_at', + 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''最后更新时间'' AFTER created_at'); + +SET @has_measure_box_code_index = ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_measurement_item' + AND index_name = 'uk_measure_box_code' +); +SET @phase2_measure_sql = IF( + @has_measure_box_code_index = 0, + 'ALTER TABLE fl_inbound_measurement_item ADD UNIQUE KEY uk_measure_box_code (tenant_id, box_code)', + 'SELECT 1' +); +PREPARE phase2_measure_statement FROM @phase2_measure_sql; +EXECUTE phase2_measure_statement; +DEALLOCATE PREPARE phase2_measure_statement; DROP PROCEDURE IF EXISTS phase2_measure_add_column; @@ -3085,13 +3196,146 @@ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; DROP PROCEDURE IF EXISTS phase2_fba_add_column; --- 旧索引可能缺少业务类型、船司和结束时间;用新索引名并存,避免破坏线上依赖。 -SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_carrier_fba_rate' AND INDEX_NAME = 'idx_fba_rate_unique_match'); -SET @sql = IF(@idx_exists = 0, - 'ALTER TABLE fl_carrier_fba_rate ADD INDEX idx_fba_rate_unique_match (tenant_id, origin_warehouse_id, business_type, channel_code, carrier_id, destination_code, charge_unit, status, effective_from, effective_to)', - 'SELECT 1'); -PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; +CREATE TABLE IF NOT EXISTS fl_price_channel_catalog ( + id BIGINT UNSIGNED NOT NULL COMMENT '渠道目录ID', + channel_code VARCHAR(64) NOT NULL COMMENT '稳定渠道编码', + display_name VARCHAR(128) NOT NULL COMMENT '平台渠道名称', + price_model VARCHAR(32) NOT NULL COMMENT '默认价格模型', + source_sheet_name VARCHAR(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet', + aliases_json JSON NOT NULL COMMENT '上传名称别名数组', + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态', + 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_channel_code (channel_code), + KEY idx_price_channel_status (status, price_model) +) ENGINE=InnoDB COMMENT='平台公共船期价格固定渠道目录'; + +CREATE TABLE IF NOT EXISTS fl_price_origin_alias ( + id BIGINT UNSIGNED NOT NULL COMMENT '起运仓别名ID', + warehouse_code VARCHAR(64) NOT NULL COMMENT '平台仓库稳定编码,不存环境专属ID', + alias_name VARCHAR(128) NOT NULL COMMENT 'Excel起运仓名称别名', + 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_origin_alias (warehouse_code, alias_name), + 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', + version_no INT UNSIGNED NOT NULL COMMENT '整表版本号', + original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名', + source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID', + source_sha256 CHAR(64) NULL COMMENT '原始文件SHA-256', + status VARCHAR(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '待确认上传记录;ACTIVE/SUPERSEDED/FAILED', + blocking_error_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '阻断错误数', + warning_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '警告数', + parse_issues_json JSON NULL COMMENT '上传解析问题及待确认重新校验结果', + channel_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '解析渠道数', + rate_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '解析价格数', + created_by BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + published_by BIGINT UNSIGNED NULL, + published_at DATETIME(3) NULL COMMENT '确认并生效时间', + superseded_at DATETIME(3) NULL COMMENT '被下一版本替换时间', + updated_by BIGINT UNSIGNED NOT NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted TINYINT(1) NOT NULL DEFAULT 0, + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_price_book_version (tenant_id, version_no), + KEY idx_price_book_status (tenant_id, status, published_at), + KEY idx_price_book_source_hash (tenant_id, source_sha256) +) ENGINE=InnoDB COMMENT='平台公共船期价格整表版本'; + +CREATE TABLE IF NOT EXISTS fl_price_book_channel ( + id BIGINT UNSIGNED NOT NULL COMMENT '版本渠道快照ID', + price_book_version_id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID', + channel_code VARCHAR(64) NOT NULL COMMENT '固定渠道编码', + channel_name VARCHAR(128) NOT NULL COMMENT '渠道名称快照', + price_model VARCHAR(32) NOT NULL COMMENT '价格模型快照', + source_sheet_name VARCHAR(128) NOT NULL COMMENT '原始Excel Sheet', + estimated_departure_date DATE NULL COMMENT '标准预计开船日期', + estimated_departure_text VARCHAR(128) NULL COMMENT '原始预计开船日期文本', + latest_warehouse_entry VARCHAR(128) NULL COMMENT '最晚进仓时间', + vessel_name VARCHAR(128) NULL COMMENT '船名', + voyage_no VARCHAR(64) NULL COMMENT '航次', + reference_transit VARCHAR(128) NULL COMMENT '参考时效', + surcharge_description VARCHAR(2000) NULL COMMENT '附加费说明,仅展示', + remark VARCHAR(2000) NULL COMMENT '渠道备注', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_price_book_channel (price_book_version_id, channel_code), + KEY idx_price_book_channel_code (channel_code, price_book_version_id) + +) ENGINE=InnoDB COMMENT='价格版本渠道及船期快照'; + +INSERT INTO fl_price_channel_catalog + (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, status) +VALUES + (1, 'MATSON_CLX_TRUCK', '美森正班海卡', 'FBA_WAREHOUSE_RATE', '美森正班海卡', JSON_ARRAY('美森正班海卡'), 'ACTIVE'), + (2, 'MATSON_MAX_TRUCK', '美森加班海卡', 'FBA_WAREHOUSE_RATE', '美森加班海卡', JSON_ARRAY('美森加班海卡'), 'ACTIVE'), + (3, 'EXX_TRUCK', 'EXX海卡', 'FBA_WAREHOUSE_RATE', 'EXX海卡', JSON_ARRAY('EXX海卡'), 'ACTIVE'), + (4, 'HEDE_TRUCK', '合德海卡', 'FBA_WAREHOUSE_RATE', '合德海卡', JSON_ARRAY('合德海卡'), 'ACTIVE'), + (5, 'EXX_RAIL_NY', '纽约EXX海铁', 'FBA_WAREHOUSE_RATE', '纽约EXX海铁', JSON_ARRAY('纽约EXX海铁'), 'ACTIVE'), + (6, 'OA_LA_TRUCK', '洛杉矶普船海卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船海卡', JSON_ARRAY('洛杉矶普船海卡'), 'ACTIVE'), + (7, 'OA_LA_FULL_TRUCK', '洛杉矶普船全卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船全卡', JSON_ARRAY('洛杉矶普船全卡'), 'ACTIVE'), + (8, 'OA_CHICAGO_RAIL', '芝加哥普船海铁', 'FBA_WAREHOUSE_RATE', '芝加哥普船海铁', JSON_ARRAY('芝加哥普船海铁'), 'ACTIVE'), + (9, 'OA_HOUSTON_TRUCK', '休斯顿普船海卡', 'FBA_WAREHOUSE_RATE', '休斯顿普船海卡', JSON_ARRAY('休斯顿普船海卡'), 'ACTIVE'), + (10, 'OA_NEW_YORK_TRUCK', '纽约普船海卡', 'FBA_WAREHOUSE_RATE', '纽约普船海卡', JSON_ARRAY('纽约普船海卡'), 'ACTIVE'), + (11, 'OA_SAVANNAH_TRUCK', '萨凡纳普船海卡', 'FBA_WAREHOUSE_RATE', '萨凡纳普船海卡', JSON_ARRAY('萨凡纳普船海卡'), 'ACTIVE'), + (12, 'MATSON_CLX_PARCEL', '美森正班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森正班海派'), 'ACTIVE'), + (13, 'MATSON_MAX_PARCEL', '美森加班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森加班海派'), 'ACTIVE'), + (14, 'HEDE_PARCEL', '合德海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('合德海派'), 'ACTIVE'), + (15, 'OA_PARCEL', '普船海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('普船海派'), 'ACTIVE') +ON DUPLICATE KEY UPDATE + display_name = VALUES(display_name), price_model = VALUES(price_model), + source_sheet_name = VALUES(source_sheet_name), aliases_json = VALUES(aliases_json), status = VALUES(status); + +INSERT INTO fl_price_origin_alias (id, warehouse_code, alias_name, status) +VALUES + (1, 'CN-YIWU-01', '华东仓(义乌)', 'ACTIVE'), + (2, 'CN-YIWU-01', '义乌仓', 'ACTIVE'), + (3, 'CN-YIWU-01', '义乌', 'ACTIVE'), + (4, 'CN-DONGGUAN-01', '华南仓(深圳)', 'ACTIVE'), + (5, '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 @@ -3228,6 +3472,62 @@ SELECT 4000003, id, 0 FROM iam_permission WHERE id IN (5102061, 5102063, 5102065, 5102066, 5102067, 5102068) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); +-- 客户尺寸计算配置权限;与 FBA 计费规则相互独立。 +DROP PROCEDURE IF EXISTS unified_guard_dimension_config_permissions; +DELIMITER $$ +CREATE PROCEDURE unified_guard_dimension_config_permissions() +BEGIN + IF EXISTS ( + SELECT 1 FROM iam_permission + WHERE (id = 5102111 AND COALESCE(permission_code, '') <> 'firstleg:dimension-config:view') + OR (id = 5102112 AND COALESCE(permission_code, '') <> 'firstleg:dimension-config:edit') + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:客户尺寸计算配置权限固定ID已被其他权限码占用'; + END IF; + IF EXISTS ( + SELECT 1 FROM iam_permission + WHERE (permission_code = 'firstleg:dimension-config:view' AND id <> 5102111) + OR (permission_code = 'firstleg:dimension-config:edit' AND id <> 5102112) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:客户尺寸计算配置权限码已绑定其他固定ID'; + END IF; +END$$ +DELIMITER ; +CALL unified_guard_dimension_config_permissions(); +DROP PROCEDURE IF EXISTS unified_guard_dimension_config_permissions; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted) +VALUES + (5102111, 0, 'PLATFORM', 'BUTTON', 5100010, 'firstleg:dimension-config:view', + '查看客户尺寸计算配置', NULL, NULL, 90, 'ACTIVE', 1, 0), + (5102112, 0, 'PLATFORM', 'BUTTON', 5100010, 'firstleg:dimension-config:edit', + '维护客户尺寸计算配置', NULL, NULL, 100, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), + status = 'ACTIVE', built_in = 1, deleted = 0; + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT r.id, p.id, 0 + FROM iam_role r + JOIN iam_permission p + ON p.tenant_id = 0 AND p.app_code = 'PLATFORM' + AND p.permission_code IN ('firstleg:dimension-config:view', 'firstleg:dimension-config:edit') + WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM' AND r.role_code = 'PLATFORM_ADMIN' +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT r.id, p.id, 0 + FROM iam_role r + JOIN iam_permission p + ON p.tenant_id = 0 AND p.app_code = 'PLATFORM' + AND p.permission_code = 'firstleg:dimension-config:view' + WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM' AND r.role_code = 'ORDER_OPERATOR' +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + -- ============================================================================ -- 来源:iam\src\main\resources\db\phase2-location-permissions.sql -- ============================================================================ @@ -3472,9 +3772,24 @@ CALL unified_assert_table('baiyue_audit', 'sys_audit_log'); CALL unified_assert_table('baiyue_event_bridge', 'bridge_inbox'); CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_id'); CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_name'); +CALL unified_assert_column('baiyue_order', 'ord_order', 'pricing_tax_mode'); CALL unified_assert_column('baiyue_order', 'ord_order', 'inbound_code'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id'); +CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'pricing_tax_mode'); 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'); +CALL unified_assert_column('baiyue_firstleg', 'fl_carrier_fba_rate', 'price_book_version_id'); +CALL unified_assert_column('baiyue_firstleg', 'fl_carrier_fba_rate', 'pricing_tax_mode'); +CALL unified_assert_column('baiyue_firstleg', 'fl_carrier_fba_rate', 'source_sheet_name'); +CALL unified_assert_column('baiyue_firstleg', 'fl_carrier_fba_rate', 'destination_type'); +CALL unified_assert_column('baiyue_firstleg', 'fl_carrier_fba_rate', 'destination_warehouse_id'); +CALL unified_assert_column('baiyue_firstleg', 'fl_carrier_fba_rate', 'destination_name'); +CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_fee', 'price_book_version_id'); CALL unified_assert_column('baiyue_file', 'file_export_task', 'execution_deadline_at'); DROP PROCEDURE IF EXISTS unified_assert_table; @@ -3494,7 +3809,8 @@ SET @phase2_permission_count = ( 'firstleg:fba-pricing:rule:edit', 'firstleg:fba-pricing:rate:view', 'firstleg:fba-pricing:rate:edit', 'firstleg:fba-pricing:calculate', 'firstleg:fba-pricing:confirm', 'firstleg:fba-pricing:view', - 'firstleg:fba-pricing:adjust', 'platform:location:list', + 'firstleg:fba-pricing:adjust', 'firstleg:dimension-config:view', + 'firstleg:dimension-config:edit', 'platform:location:list', 'platform:location:create', 'platform:location:edit', 'platform:location:status', 'platform:location:delete', 'platform:pallet:list', 'platform:pallet:create', 'platform:pallet:edit', 'platform:pallet:delete' @@ -3505,9 +3821,9 @@ DROP PROCEDURE IF EXISTS unified_assert_permission_count; DELIMITER $$ CREATE PROCEDURE unified_assert_permission_count() BEGIN - IF @phase2_permission_count <> 28 THEN + IF @phase2_permission_count <> 30 THEN SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:二期 IAM 权限资源未达到 28 项'; + SET MESSAGE_TEXT = '统一迁移失败:二期 IAM 权限资源未达到 30 项'; END IF; END$$ DELIMITER ; @@ -5742,16 +6058,25 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM' ) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); --- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql -SET NAMES utf8mb4; -USE baiyue_firstleg; +CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_customer_dimension_config ( + id BIGINT UNSIGNED NOT NULL COMMENT '配置ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', + edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数', + remark VARCHAR(500) NULL COMMENT '备注', + initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id), + CONSTRAINT chk_customer_dimension_edge_addition + CHECK (edge_addition_cm >= 0.001 AND edge_addition_cm <= 100.000) +) ENGINE=InnoDB COMMENT='客户尺寸计算配置'; -/* - * 三期主子入仓单结构迁移。 - * 仅建立结构和数据库保护,不自动拆分或回填存量业务数据。 - * tenant_id 继续表示客户隔离键;数量统一使用 package_count。 - */ -DROP PROCEDURE IF EXISTS phase3_inbound_add_column; +DROP PROCEDURE IF EXISTS unified_assert_final_extensions; DELIMITER $$ CREATE PROCEDURE phase3_inbound_add_column( IN p_table_name VARCHAR(64), @@ -5843,6 +6168,17 @@ 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' @@ -6324,6 +6660,83 @@ BEGIN ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '旧订单唯一索引仍阻止创建子入仓单'; END IF; + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'baiyue_firstleg' + AND table_name = 'fl_inbound_carton_label') <> 1 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:稳定箱号映射表未创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' + AND table_name = 'fl_inbound_measurement_item' + AND column_name IN ('box_code', 'machine_no', 'device_measured_at', 'updated_at')) <> 4 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:单箱当前测量字段未完整创建'; + END IF; + IF (SELECT COUNT(DISTINCT CONCAT(table_name, '.', index_name)) + FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' + AND ((table_name = 'fl_inbound_carton_label' + AND index_name IN ('uk_carton_label_box_code', 'uk_carton_label_index')) + OR (table_name = 'fl_inbound_measurement_item' + AND index_name = 'uk_measure_box_code')) + AND non_unique = 0) <> 3 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:稳定箱号或单箱测量唯一约束未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'baiyue_firstleg' + AND table_name = 'fl_customer_dimension_config') <> 1 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:客户尺寸计算配置表未创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' + AND table_name = 'fl_customer_dimension_config' + AND index_name = 'uk_customer_dimension_config_tenant' + AND non_unique = 0) <> 1 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:客户尺寸计算配置唯一约束未创建'; + END IF; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission + WHERE id = 5100010 AND tenant_id = 0 AND app_code = 'PLATFORM' + AND permission_code = 'platform:customer:list' AND permission_name = '企业客户' + AND status = 'ACTIVE' AND deleted = 0) <> 1 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:客户尺寸计算配置权限父节点企业客户不存在'; + END IF; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission + WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND permission_type = 'BUTTON' + AND parent_id = 5100010 AND status = 'ACTIVE' AND deleted = 0 + AND ((id = 5102111 AND permission_code = 'firstleg:dimension-config:view') + OR (id = 5102112 AND permission_code = 'firstleg:dimension-config:edit'))) <> 2 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:客户尺寸计算配置权限码、固定ID或父节点不正确'; + END IF; + IF (SELECT COUNT(DISTINCT p.permission_code) + FROM baiyue_iam.iam_role_permission rp + JOIN baiyue_iam.iam_role r ON r.id = rp.role_id AND r.tenant_id = rp.tenant_id + JOIN baiyue_iam.iam_permission p ON p.id = rp.permission_id AND p.tenant_id = rp.tenant_id + WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM' AND r.role_code = 'PLATFORM_ADMIN' + AND p.permission_code IN ('firstleg:dimension-config:view', 'firstleg:dimension-config:edit')) <> 2 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:平台管理员未获得客户尺寸计算配置读写权限'; + END IF; + IF (SELECT COUNT(DISTINCT p.permission_code) + FROM baiyue_iam.iam_role_permission rp + JOIN baiyue_iam.iam_role r ON r.id = rp.role_id AND r.tenant_id = rp.tenant_id + JOIN baiyue_iam.iam_permission p ON p.id = rp.permission_id AND p.tenant_id = rp.tenant_id + WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM' AND r.role_code = 'ORDER_OPERATOR' + AND p.permission_code = 'firstleg:dimension-config:view') <> 1 + OR (SELECT COUNT(*) + FROM baiyue_iam.iam_role_permission rp + JOIN baiyue_iam.iam_role r ON r.id = rp.role_id AND r.tenant_id = rp.tenant_id + JOIN baiyue_iam.iam_permission p ON p.id = rp.permission_id AND p.tenant_id = rp.tenant_id + WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM' AND r.role_code = 'ORDER_OPERATOR' + AND p.permission_code = 'firstleg:dimension-config:edit') <> 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:订单运营角色的客户尺寸计算配置权限不是只读'; + END IF; END$$ DELIMITER ; CALL phase3_assert_inbound_parent_child_schema(); @@ -6349,3 +6762,5 @@ WHERE from_status = 'WAIT_SPLIT'; UPDATE fl_inbound_status_history SET to_status = 'INBOUNDED' WHERE to_status = 'WAIT_SPLIT'; +SELECT 'PASS' AS migration_status, + '统一增量全部执行完成;箱号映射、单箱测量、三期出仓、费用、公共资源及可靠审计结构校验通过' AS message; 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/controller/InternalPriceBookFileController.java b/file/src/main/java/com/baiyue/file/controller/InternalPriceBookFileController.java new file mode 100644 index 000000000..1952954f4 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/InternalPriceBookFileController.java @@ -0,0 +1,89 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.service.PriceBookFileService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** Internal source-file API used only by Firstleg price-book workflows. */ +@RestController +@RequestMapping("/internal/file/v1/price-books") +public class InternalPriceBookFileController { + @Resource + private PriceBookFileService service; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; + private byte[] expectedToken; + + @PostConstruct + void initializeToken() { + expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); + } + + @PostMapping(value = "/{businessId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse upload( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long businessId, + @RequestPart("file") MultipartFile file) { + requireAccess(token, operatorId, businessId, null); + return ApiResponse.success(service.upload(businessId, operatorId, file)); + } + + @GetMapping("/{businessId}/{fileId}/metadata") + public ApiResponse metadata( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long businessId, + @PathVariable long fileId) { + requireAccess(token, operatorId, businessId, fileId); + return ApiResponse.success(service.metadata(businessId, fileId)); + } + + @GetMapping("/{businessId}/{fileId}/content") + public ResponseEntity download( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long businessId, + @PathVariable long fileId) { + requireAccess(token, operatorId, businessId, fileId); + PriceBookFileService.Download download = service.download(businessId, fileId); + PriceBookFileService.FileMetadata metadata = download.metadata(); + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .contentType(MediaType.parseMediaType(metadata.contentType())) + .contentLength(download.content().length) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(metadata.fileName(), StandardCharsets.UTF_8).build().toString()) + .header("X-Content-Type-Options", "nosniff") + .body(download.content()); + } + + private void requireAccess(String token, long operatorId, long businessId, Long fileId) { + if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); + } + if (operatorId <= 0 || businessId <= 0 || (fileId != null && fileId <= 0)) { + throw new BizException(ResultCode.BAD_REQUEST, "操作主体、价格版本业务和文件ID必须大于0"); + } + } +} diff --git a/file/src/main/java/com/baiyue/file/controller/InternalProductImageController.java b/file/src/main/java/com/baiyue/file/controller/InternalProductImageController.java new file mode 100644 index 000000000..51a135d08 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/InternalProductImageController.java @@ -0,0 +1,48 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.service.ProductImageService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@RestController +@RequestMapping("/internal/file/v1/product-images") +public class InternalProductImageController { + @Resource private ProductImageService images; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + private byte[] expectedToken; + + @PostConstruct + void initializeToken() { + expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); + } + + @GetMapping("/{tenantId}/{fileId}") + public ApiResponse validate( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long tenantId, + @PathVariable long fileId) { + requireToken(token); + if (tenantId <= 0 || fileId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "客户和文件ID必须大于0"); + } + return ApiResponse.success(images.validate(tenantId, fileId)); + } + + private void requireToken(String token) { + if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); + } + } +} diff --git a/file/src/main/java/com/baiyue/file/controller/MerchantProductImageController.java b/file/src/main/java/com/baiyue/file/controller/MerchantProductImageController.java new file mode 100644 index 000000000..060fea945 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/MerchantProductImageController.java @@ -0,0 +1,62 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.service.ProductImageService; +import jakarta.annotation.Resource; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.charset.StandardCharsets; + +@RestController +@RequestMapping("/api/merchant/v1/files/product-images") +public class MerchantProductImageController { + @Resource private ProductImageService images; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAnyAuthority('merchant:order:create','merchant:order:edit')") + public ApiResponse upload( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestPart("file") MultipartFile file) { + long tenantId = merchantTenant(user); + return ApiResponse.success(images.upload(tenantId, user.userId(), file)); + } + + @GetMapping("/{fileId}") + @PreAuthorize("hasAnyAuthority('merchant:order:create','merchant:order:edit','merchant:order:view')") + public ResponseEntity download( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long fileId) { + var image = images.download(merchantTenant(user), fileId); + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .contentType(MediaType.parseMediaType(image.contentType())) + .contentLength(image.content().length) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline() + .filename(image.fileName(), StandardCharsets.UTF_8).build().toString()) + .body(image.content()); + } + + private static long merchantTenant(AuthenticatedUser user) { + if (user == null || !"MERCHANT".equalsIgnoreCase(user.appCode()) + || user.tenantId() == null || user.tenantId() <= 0 || user.userId() == null || user.userId() <= 0) { + throw new BizException(ResultCode.FORBIDDEN, "Merchant identity is required"); + } + return user.tenantId(); + } +} 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..82db4c2b3 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, @@ -86,11 +87,12 @@ public class MeasurementImageService { if (!"INBOUND_MEASUREMENT_IMAGE".equals(object.getBusinessType())) { throw new BizException(ResultCode.CONFLICT, "仅允许清理第三方尺寸测量证据图片"); } - object.setDeleted(true); object.setStatus("DELETED"); object.setUpdatedBy(operatorId); object.setUpdatedAt(LocalDateTime.now()); - files.updateById(object); + if (files.updateById(object) != 1 || files.deleteById(object.getId()) != 1) { + throw new BizException(ResultCode.CONFLICT, "测量图片已被删除,请刷新后重试"); + } storage.delete(object.getObjectKey()); } @@ -100,6 +102,7 @@ public class MeasurementImageService { .eq(FileObjectEntity::getId, fileId) .in(FileObjectEntity::getBusinessType, "INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE") .eq(FileObjectEntity::getBusinessId, Long.toString(inboundId)) + .eq(FileObjectEntity::getStatus, "ACTIVE") .eq(FileObjectEntity::getDeleted, false)); if (object == null) throw new BizException(ResultCode.NOT_FOUND, "称重图片不存在"); return object; @@ -116,7 +119,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 +128,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/main/java/com/baiyue/file/service/PriceBookFileService.java b/file/src/main/java/com/baiyue/file/service/PriceBookFileService.java new file mode 100644 index 000000000..214c38c64 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/PriceBookFileService.java @@ -0,0 +1,88 @@ +package com.baiyue.file.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; + +/** Persists platform price-book source files and enforces exact business ownership. */ +@Service +public class PriceBookFileService { + public static final long PLATFORM_SCOPE = 0L; + public static final String BUSINESS_TYPE = "FBA_PRICE_BOOK_SOURCE"; + + @Resource + private PriceBookFileStorage storage; + @Resource + private FileObjectMapper files; + + @Transactional + public FileMetadata upload(long businessId, long operatorId, MultipartFile file) { + PriceBookFileStorage.Stored stored = storage.store(businessId, file); + FileObjectEntity object = new FileObjectEntity(); + object.setTenantId(PLATFORM_SCOPE); + object.setBusinessType(BUSINESS_TYPE); + object.setBusinessId(Long.toString(businessId)); + object.setOriginalName(stored.originalName()); + object.setObjectKey(stored.objectKey()); + object.setStorageProvider("LOCAL"); + object.setBucketName("price-books"); + object.setContentType(stored.contentType()); + object.setSizeBytes(stored.sizeBytes()); + object.setSha256(stored.sha256()); + object.setStatus("ACTIVE"); + object.setCreatedBy(operatorId); + object.setUpdatedBy(operatorId); + object.setCreatedAt(LocalDateTime.now()); + object.setUpdatedAt(LocalDateTime.now()); + object.setDeleted(false); + object.setVersion(0); + try { + if (files.insert(object) != 1) { + throw new BizException(ResultCode.INTERNAL_ERROR, "价格表文件元数据保存失败"); + } + } catch (RuntimeException exception) { + storage.delete(stored.objectKey()); + throw exception; + } + return metadata(object); + } + + public FileMetadata metadata(long businessId, long fileId) { + return metadata(requireObject(businessId, fileId)); + } + + public Download download(long businessId, long fileId) { + FileObjectEntity object = requireObject(businessId, fileId); + return new Download(metadata(object), storage.read(object.getObjectKey())); + } + + private FileObjectEntity requireObject(long businessId, long fileId) { + FileObjectEntity object = files.selectOne(new LambdaQueryWrapper() + .eq(FileObjectEntity::getTenantId, PLATFORM_SCOPE) + .eq(FileObjectEntity::getBusinessType, BUSINESS_TYPE) + .eq(FileObjectEntity::getBusinessId, Long.toString(businessId)) + .eq(FileObjectEntity::getId, fileId) + .eq(FileObjectEntity::getStatus, "ACTIVE") + .eq(FileObjectEntity::getDeleted, false)); + if (object == null) throw new BizException(ResultCode.NOT_FOUND, "价格表原文件不存在"); + return object; + } + + private static FileMetadata metadata(FileObjectEntity object) { + return new FileMetadata(object.getId(), Long.parseLong(object.getBusinessId()), + object.getOriginalName(), object.getContentType(), object.getSizeBytes(), object.getSha256(), + object.getBusinessType()); + } + + public record FileMetadata(Long fileId, Long businessId, String fileName, String contentType, + Long sizeBytes, String sha256, String businessType) {} + public record Download(FileMetadata metadata, byte[] content) {} +} diff --git a/file/src/main/java/com/baiyue/file/service/PriceBookFileStorage.java b/file/src/main/java/com/baiyue/file/service/PriceBookFileStorage.java new file mode 100644 index 000000000..df92dc5b6 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/PriceBookFileStorage.java @@ -0,0 +1,173 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** Stores original price-book XLSX files without interpreting workbook table metadata. */ +@Component +public class PriceBookFileStorage { + static final String XLSX_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + private static final Set ACCEPTED_CONTENT_TYPES = Set.of( + XLSX_CONTENT_TYPE, "application/octet-stream", "application/zip"); + private static final Set REQUIRED_ENTRIES = Set.of( + "[Content_Types].xml", "xl/workbook.xml", "xl/_rels/workbook.xml.rels"); + private static final int MAX_ZIP_ENTRIES = 20_000; + private static final long MAX_EXPANDED_BYTES = 256L * 1024 * 1024; + + @Value("${baiyue.file.storage-root}") + private Path configuredRoot; + @Value("${baiyue.file.price-book.max-size-bytes:10485760}") + private long maxSizeBytes; + private Path root; + + @PostConstruct + void initializeRoot() { + root = configuredRoot.toAbsolutePath().normalize(); + } + + public Stored store(long businessId, MultipartFile file) { + validateFile(file); + String originalName = safeName(file); + verifyXlsxPackage(file); + String objectKey = "price-books/0/" + businessId + "/" + UUID.randomUUID() + ".xlsx"; + Path target = resolve(objectKey); + try { + Files.createDirectories(target.getParent()); + file.transferTo(target); + return new Stored(objectKey, originalName, XLSX_CONTENT_TYPE, Files.size(target), sha256(target)); + } catch (IOException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件保存失败"); + } + } + + public byte[] read(String objectKey) { + Path target = resolve(objectKey); + if (!Files.isRegularFile(target)) { + throw new BizException(ResultCode.NOT_FOUND, "价格表原文件不存在"); + } + try { + return Files.readAllBytes(target); + } catch (IOException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件读取失败"); + } + } + + public void delete(String objectKey) { + Path target = resolve(objectKey); + try { + Files.deleteIfExists(target); + } catch (IOException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件清理失败"); + } + } + + private void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) throw bad("价格表文件不能为空"); + if (maxSizeBytes <= 0 || file.getSize() > maxSizeBytes) { + throw bad("价格表文件超过允许大小"); + } + String name = file.getOriginalFilename(); + if (name == null || name.isBlank() || name.length() > 200 + || name.indexOf('\0') >= 0 || name.contains("/") || name.contains("\\")) { + throw bad("价格表文件名不安全"); + } + if (!name.toLowerCase(Locale.ROOT).endsWith(".xlsx")) { + throw bad("价格表仅支持XLSX文件"); + } + String contentType = file.getContentType() == null + ? "" : file.getContentType().toLowerCase(Locale.ROOT); + if (!ACCEPTED_CONTENT_TYPES.contains(contentType)) { + throw bad("价格表文件类型不正确"); + } + } + + private void verifyXlsxPackage(MultipartFile file) { + Set found = new java.util.HashSet<>(); + int entries = 0; + long expandedBytes = 0; + try (InputStream raw = new BufferedInputStream(file.getInputStream())) { + raw.mark(4); + if (raw.read() != 'P' || raw.read() != 'K' || raw.read() != 3 || raw.read() != 4) { + throw bad("上传内容不是有效XLSX文件"); + } + raw.reset(); + try (ZipInputStream zip = new ZipInputStream(raw)) { + byte[] buffer = new byte[8192]; + for (ZipEntry entry; (entry = zip.getNextEntry()) != null;) { + if (++entries > MAX_ZIP_ENTRIES || unsafeEntry(entry.getName())) { + throw bad("XLSX压缩包结构不安全"); + } + if (REQUIRED_ENTRIES.contains(entry.getName())) found.add(entry.getName()); + for (int count; (count = zip.read(buffer)) >= 0;) { + expandedBytes += count; + if (expandedBytes > MAX_EXPANDED_BYTES) { + throw bad("XLSX解压内容超过安全限制"); + } + } + } + } + } catch (BizException exception) { + throw exception; + } catch (IOException exception) { + throw bad("上传内容不是有效XLSX文件"); + } + if (!found.containsAll(REQUIRED_ENTRIES)) throw bad("上传内容缺少XLSX必要结构"); + } + + private Path resolve(String objectKey) { + if (objectKey == null || !objectKey.matches("price-books/0/\\d+/[0-9a-fA-F-]{36}\\.xlsx")) { + throw new BizException(ResultCode.FORBIDDEN, "价格表文件路径无效"); + } + Path target = root.resolve(objectKey).normalize(); + if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界"); + return target; + } + + private static boolean unsafeEntry(String name) { + return name == null || name.startsWith("/") || name.startsWith("\\") + || name.contains("../") || name.contains("..\\") || name.indexOf('\0') >= 0; + } + + private static String safeName(MultipartFile file) { + return file.getOriginalFilename().trim(); + } + + private static String sha256(Path path) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream input = Files.newInputStream(path)) { + byte[] buffer = new byte[8192]; + for (int count; (count = input.read(buffer)) >= 0;) digest.update(buffer, 0, count); + } + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private static BizException bad(String message) { + return new BizException(ResultCode.BAD_REQUEST, message); + } + + public record Stored(String objectKey, String originalName, String contentType, + long sizeBytes, String sha256) {} +} diff --git a/file/src/main/java/com/baiyue/file/service/ProductImageService.java b/file/src/main/java/com/baiyue/file/service/ProductImageService.java new file mode 100644 index 000000000..083376fe5 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/ProductImageService.java @@ -0,0 +1,76 @@ +package com.baiyue.file.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; + +@Service +public class ProductImageService { + public static final String BUSINESS_TYPE = "ORDER_PRODUCT_IMAGE"; + @Resource private ProductImageStorage storage; + @Resource private FileObjectMapper files; + + @Transactional + public UploadResult upload(long tenantId, long operatorId, MultipartFile file) { + var stored = storage.store(tenantId, file); + FileObjectEntity object = new FileObjectEntity(); + object.setTenantId(tenantId); + object.setBusinessType(BUSINESS_TYPE); + object.setOriginalName(stored.originalName()); + object.setObjectKey(stored.objectKey()); + object.setStorageProvider("LOCAL"); + object.setBucketName("product-images"); + object.setContentType(stored.contentType()); + object.setSizeBytes(stored.size()); + object.setSha256(stored.sha256()); + object.setStatus("ACTIVE"); + object.setCreatedBy(operatorId); + object.setUpdatedBy(operatorId); + object.setCreatedAt(LocalDateTime.now()); + object.setUpdatedAt(LocalDateTime.now()); + object.setDeleted(false); + object.setVersion(0); + try { + files.insert(object); + } catch (RuntimeException exception) { + storage.delete(stored.objectKey()); + throw exception; + } + return metadata(object); + } + + public UploadResult validate(long tenantId, long fileId) { + return metadata(requireObject(tenantId, fileId)); + } + + public Download download(long tenantId, long fileId) { + FileObjectEntity object = requireObject(tenantId, fileId); + return new Download(object.getOriginalName(), object.getContentType(), storage.read(object.getObjectKey())); + } + + private FileObjectEntity requireObject(long tenantId, long fileId) { + FileObjectEntity object = files.selectOne(new LambdaQueryWrapper() + .eq(FileObjectEntity::getTenantId, tenantId) + .eq(FileObjectEntity::getId, fileId) + .eq(FileObjectEntity::getBusinessType, BUSINESS_TYPE) + .eq(FileObjectEntity::getStatus, "ACTIVE") + .eq(FileObjectEntity::getDeleted, false)); + if (object == null) throw new BizException(ResultCode.NOT_FOUND, "产品图片不存在"); + return object; + } + + private static UploadResult metadata(FileObjectEntity object) { + return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(), object.getSizeBytes()); + } + + public record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes) {} + public record Download(String fileName, String contentType, byte[] content) {} +} diff --git a/file/src/main/java/com/baiyue/file/service/ProductImageStorage.java b/file/src/main/java/com/baiyue/file/service/ProductImageStorage.java new file mode 100644 index 000000000..b6aef99dc --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/ProductImageStorage.java @@ -0,0 +1,128 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Iterator; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +@Component +public class ProductImageStorage { + private static final long MAX_SIZE = 5L * 1024 * 1024; + private static final Set TYPES = Set.of("image/jpeg", "image/png"); + @Value("${baiyue.file.storage-root}") private Path configuredRoot; + private Path root; + + @PostConstruct + void initializeRoot() { + root = configuredRoot.toAbsolutePath().normalize(); + } + + public Stored store(long tenantId, MultipartFile file) { + if (file == null || file.isEmpty()) throw bad("产品图片不能为空"); + if (file.getSize() > MAX_SIZE) throw bad("产品图片不能超过5MB"); + String type = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT); + if (!TYPES.contains(type)) throw bad("产品图片仅支持JPG、PNG"); + verifyImage(file, type); + String extension = "image/png".equals(type) ? ".png" : ".jpg"; + String objectKey = "product-images/" + tenantId + "/" + UUID.randomUUID() + extension; + Path target = root.resolve(objectKey).normalize(); + if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界"); + try { + Files.createDirectories(target.getParent()); + file.transferTo(target); + return new Stored(objectKey, type, file.getSize(), sha256(target), safeName(file)); + } catch (IOException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "产品图片保存失败"); + } + } + + public byte[] read(String objectKey) { + Path path = root.resolve(objectKey).normalize(); + if (!path.startsWith(root) || !Files.isRegularFile(path)) { + throw new BizException(ResultCode.NOT_FOUND, "产品图片不存在"); + } + try { + return Files.readAllBytes(path); + } catch (IOException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "产品图片读取失败"); + } + } + + public void delete(String objectKey) { + Path path = root.resolve(objectKey).normalize(); + if (!path.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界"); + try { + Files.deleteIfExists(path); + } catch (IOException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "产品图片清理失败"); + } + } + + private void verifyImage(MultipartFile file, String declaredType) { + try (InputStream input = file.getInputStream(); + ImageInputStream imageInput = ImageIO.createImageInputStream(input)) { + if (imageInput == null) throw bad("上传内容不是有效图片"); + Iterator readers = ImageIO.getImageReaders(imageInput); + if (!readers.hasNext()) throw bad("上传内容不是有效图片"); + ImageReader reader = readers.next(); + try { + String format = reader.getFormatName().toLowerCase(Locale.ROOT); + boolean matches = "image/png".equals(declaredType) ? "png".equals(format) + : "jpeg".equals(format) || "jpg".equals(format); + if (!matches) throw bad("图片内容与文件类型不一致"); + reader.setInput(imageInput, true, true); + reader.read(0); + } finally { + reader.dispose(); + } + } catch (IOException exception) { + throw bad("上传内容不是有效图片"); + } + } + + private static String sha256(Path path) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream input = Files.newInputStream(path)) { + byte[] buffer = new byte[8192]; + for (int count; (count = input.read(buffer)) >= 0;) digest.update(buffer, 0, count); + } + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private static String safeName(MultipartFile file) { + String name = file.getOriginalFilename(); + if (name == null || name.isBlank()) return "product-image"; + String normalized = name.replace('\\', '/'); + String leaf = normalized.substring(normalized.lastIndexOf('/') + 1) + .replaceAll("[\\p{Cntrl}]", "").trim(); + if (leaf.isEmpty()) return "product-image"; + return leaf.length() <= 255 ? leaf : leaf.substring(leaf.length() - 255); + } + + private static BizException bad(String message) { + return new BizException(ResultCode.BAD_REQUEST, message); + } + + public record Stored(String objectKey, String contentType, long size, String sha256, String originalName) {} +} diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index 88f3791af..79ac974ff 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -55,6 +55,8 @@ baiyue: url: ${INTEGRATION_SERVICE_URL:} file: storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads} + price-book: + max-size-bytes: ${FILE_PRICE_BOOK_MAX_SIZE_BYTES:10485760} export: retention-days: ${FILE_EXPORT_RETENTION_DAYS:7} legacy-worker-enabled: false 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/controller/InternalPriceBookFileControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalPriceBookFileControllerTest.java new file mode 100644 index 000000000..e7fde88d1 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/InternalPriceBookFileControllerTest.java @@ -0,0 +1,67 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.service.PriceBookFileService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(InternalPriceBookFileController.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class InternalPriceBookFileControllerTest { + @MockBean PriceBookFileService service; + @Resource InternalPriceBookFileController controller; + + @Test + void uploadsWithAuthenticatedOperatorAndBusinessId() { + var file = new MockMultipartFile("file", "price.xlsx", "application/octet-stream", new byte[]{1}); + var metadata = metadata(); + when(service.upload(41L, 7L, file)).thenReturn(metadata); + + var response = controller.upload("internal-token", 7L, 41L, file); + + assertThat(response.data()).isEqualTo(metadata); + verify(service).upload(41L, 7L, file); + } + + @Test + void rejectsInvalidToken() { + assertThatThrownBy(() -> controller.metadata("bad-token", 7L, 41L, 99L)) + .isInstanceOf(BizException.class).hasMessageContaining("凭证无效"); + } + + @Test + void rejectsMissingOperatorOrBusinessOwnership() { + assertThatThrownBy(() -> controller.metadata("internal-token", 0L, 41L, 99L)) + .isInstanceOf(BizException.class).hasMessageContaining("必须大于0"); + assertThatThrownBy(() -> controller.metadata("internal-token", 7L, 0L, 99L)) + .isInstanceOf(BizException.class).hasMessageContaining("必须大于0"); + } + + @Test + void downloadsAsAttachmentWithoutCaching() { + PriceBookFileService.FileMetadata metadata = metadata(); + when(service.download(41L, 99L)).thenReturn( + new PriceBookFileService.Download(metadata, new byte[]{1, 2, 3})); + + var response = controller.download("internal-token", 7L, 41L, 99L); + + assertThat(response.getBody()).containsExactly(1, 2, 3); + assertThat(response.getHeaders().getCacheControl()).contains("no-store"); + assertThat(response.getHeaders().getContentDisposition().isAttachment()).isTrue(); + } + + private static PriceBookFileService.FileMetadata metadata() { + return new PriceBookFileService.FileMetadata(99L, 41L, "price.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 3L, "hash", + PriceBookFileService.BUSINESS_TYPE); + } +} diff --git a/file/src/test/java/com/baiyue/file/controller/ProductImageControllerContractTest.java b/file/src/test/java/com/baiyue/file/controller/ProductImageControllerContractTest.java new file mode 100644 index 000000000..d9828b371 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/ProductImageControllerContractTest.java @@ -0,0 +1,83 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.service.ProductImageService; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProductImageControllerContractTest { + @Test + void merchantRouteUsesMultipartAndOrderPermissions() throws Exception { + RequestMapping mapping = MerchantProductImageController.class.getAnnotation(RequestMapping.class); + assertThat(mapping.value()).contains("/api/merchant/v1/files/product-images"); + var upload = MerchantProductImageController.class.getDeclaredMethod( + "upload", AuthenticatedUser.class, org.springframework.web.multipart.MultipartFile.class); + assertThat(upload.getAnnotation(PostMapping.class).consumes()) + .contains(MediaType.MULTIPART_FORM_DATA_VALUE); + assertThat(upload.getAnnotation(PreAuthorize.class).value()) + .contains("merchant:order:create").contains("merchant:order:edit"); + } + + @Test + void merchantUploadAndDownloadAlwaysUseAuthenticatedCustomer() { + ProductImageService service = mock(ProductImageService.class); + MerchantProductImageController controller = new MerchantProductImageController(); + ReflectionTestUtils.setField(controller, "images", service); + var user = merchant(10001L); + var file = new MockMultipartFile("file", "product.png", "image/png", new byte[] {1}); + var uploaded = new ProductImageService.UploadResult(31L, "product.png", "image/png", 1L); + when(service.upload(10001L, 2L, file)).thenReturn(uploaded); + when(service.download(10001L, 31L)).thenReturn( + new ProductImageService.Download("product.png", "image/png", new byte[] {1})); + + assertThat(controller.upload(user, file).data()).isEqualTo(uploaded); + assertThat(controller.download(user, 31L).getBody()).containsExactly(1); + verify(service).upload(10001L, 2L, file); + verify(service).download(10001L, 31L); + } + + @Test + void internalValidationRequiresSharedTokenAndForwardsCustomerScope() { + ProductImageService service = mock(ProductImageService.class); + InternalProductImageController controller = new InternalProductImageController(); + ReflectionTestUtils.setField(controller, "images", service); + ReflectionTestUtils.setField(controller, "internalToken", "token"); + controller.initializeToken(); + var metadata = new ProductImageService.UploadResult(31L, "product.png", "image/png", 1L); + when(service.validate(10001L, 31L)).thenReturn(metadata); + + assertThat(controller.validate("token", 10001L, 31L).data()).isEqualTo(metadata); + assertThatThrownBy(() -> controller.validate("wrong", 10001L, 31L)) + .isInstanceOf(BizException.class).hasMessageContaining("凭证无效"); + verify(service).validate(10001L, 31L); + } + + @Test + void rejectsPlatformOrMalformedIdentityBeforeFileAccess() { + ProductImageService service = mock(ProductImageService.class); + MerchantProductImageController controller = new MerchantProductImageController(); + ReflectionTestUtils.setField(controller, "images", service); + var platform = new AuthenticatedUser(1L, 0L, 0L, 1L, "admin", "PLATFORM", "WEB", Set.of()); + var file = new MockMultipartFile("file", "product.png", "image/png", new byte[] {1}); + + assertThatThrownBy(() -> controller.upload(platform, file)).isInstanceOf(BizException.class); + } + + private static AuthenticatedUser merchant(long tenantId) { + return new AuthenticatedUser(2L, tenantId, tenantId, 2L, "merchant", "MERCHANT", "WEB", Set.of()); + } +} 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..e579cedd5 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,10 +19,14 @@ 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; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -83,7 +88,7 @@ class MeasurementImageServiceEvidenceTest { LambdaQueryWrapper query = invocation.getArgument(0); query.getSqlSegment(); assertThat(query.getParamNameValuePairs().values()) - .contains(11L, 41L, "21", false); + .contains(11L, 41L, "21", "ACTIVE", false); return null; }); @@ -91,4 +96,70 @@ 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"); + } + + @Test + void deletesMeasurementEvidenceThroughTheConfiguredLogicalDelete() { + FileObjectEntity object = measurementEvidence(); + when(files.selectOne(any())).thenReturn(object); + when(files.updateById(object)).thenReturn(1); + when(files.deleteById(41L)).thenReturn(1); + + service.deleteMeasurementEvidence(11L, 21L, 41L, 31L); + + assertThat(object.getStatus()).isEqualTo("DELETED"); + assertThat(object.getDeleted()).isFalse(); + var ordered = inOrder(files, storage); + ordered.verify(files).updateById(object); + ordered.verify(files).deleteById(41L); + ordered.verify(storage).delete(object.getObjectKey()); + } + + @Test + void doesNotDeleteStoredContentWhenLogicalDeleteLosesTheRace() { + FileObjectEntity object = measurementEvidence(); + when(files.selectOne(any())).thenReturn(object); + when(files.updateById(object)).thenReturn(1); + when(files.deleteById(41L)).thenReturn(0); + + assertThatThrownBy(() -> service.deleteMeasurementEvidence(11L, 21L, 41L, 31L)) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class) + .hasMessageContaining("已被删除"); + + verify(storage, never()).delete(any()); + } + + private FileObjectEntity measurementEvidence() { + FileObjectEntity object = new FileObjectEntity(); + object.setId(41L); + object.setTenantId(11L); + object.setBusinessType("INBOUND_MEASUREMENT_IMAGE"); + object.setBusinessId("21"); + object.setObjectKey("measurements/11/21/evidence.png"); + object.setStatus("ACTIVE"); + object.setDeleted(false); + return object; + } } diff --git a/file/src/test/java/com/baiyue/file/service/PriceBookFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/PriceBookFileServiceTest.java new file mode 100644 index 000000000..4c8637a86 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/PriceBookFileServiceTest.java @@ -0,0 +1,79 @@ +package com.baiyue.file.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PriceBookFileServiceTest { + @Mock PriceBookFileStorage storage; + @Mock FileObjectMapper files; + @InjectMocks PriceBookFileService service; + + @Test + void persistsPlatformOwnedPriceBookMetadata() { + var file = new MockMultipartFile("file", "price.xlsx", PriceBookFileStorage.XLSX_CONTENT_TYPE, + new byte[]{1}); + when(storage.store(41L, file)).thenReturn(new PriceBookFileStorage.Stored( + "price-books/0/41/a.xlsx", "price.xlsx", PriceBookFileStorage.XLSX_CONTENT_TYPE, + 1L, "hash")); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { + FileObjectEntity object = invocation.getArgument(0); + object.setId(99L); + return 1; + }); + + PriceBookFileService.FileMetadata result = service.upload(41L, 7L, file); + + assertThat(result.fileId()).isEqualTo(99L); + assertThat(result.businessId()).isEqualTo(41L); + assertThat(result.businessType()).isEqualTo(PriceBookFileService.BUSINESS_TYPE); + verify(files).insert(any(FileObjectEntity.class)); + } + + @Test + void rejectsDownloadWhenBusinessOwnershipDoesNotMatch() { + when(files.selectOne(any(Wrapper.class))).thenReturn(null); + assertThatThrownBy(() -> service.download(42L, 99L)) + .isInstanceOf(BizException.class).hasMessageContaining("不存在"); + } + + @Test + void downloadsOnlyOwnedPriceBookObject() { + FileObjectEntity object = object(42L, 99L); + when(files.selectOne(any(Wrapper.class))).thenReturn(object); + when(storage.read("price-books/0/42/a.xlsx")).thenReturn(new byte[]{1, 2}); + + PriceBookFileService.Download result = service.download(42L, 99L); + + assertThat(result.content()).containsExactly(1, 2); + assertThat(result.metadata().businessId()).isEqualTo(42L); + } + + private static FileObjectEntity object(long businessId, long fileId) { + FileObjectEntity object = new FileObjectEntity(); + object.setId(fileId); + object.setTenantId(0L); + object.setBusinessId(Long.toString(businessId)); + object.setBusinessType(PriceBookFileService.BUSINESS_TYPE); + object.setOriginalName("price.xlsx"); + object.setObjectKey("price-books/0/" + businessId + "/a.xlsx"); + object.setContentType(PriceBookFileStorage.XLSX_CONTENT_TYPE); + object.setSizeBytes(2L); + object.setSha256("hash"); + return object; + } +} diff --git a/file/src/test/java/com/baiyue/file/service/PriceBookFileStorageTest.java b/file/src/test/java/com/baiyue/file/service/PriceBookFileStorageTest.java new file mode 100644 index 000000000..e07285030 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/PriceBookFileStorageTest.java @@ -0,0 +1,97 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PriceBookFileStorageTest { + private static final String XLSX = PriceBookFileStorage.XLSX_CONTENT_TYPE; + @TempDir Path root; + private PriceBookFileStorage storage; + + @BeforeEach + void setUp() { + storage = new PriceBookFileStorage(); + ReflectionTestUtils.setField(storage, "configuredRoot", root); + ReflectionTestUtils.setField(storage, "maxSizeBytes", 1024L * 1024); + storage.initializeRoot(); + } + + @Test + void storesValidPackageEvenWhenTableMetadataIsMalformed() throws IOException { + Map entries = requiredEntries(); + entries.put("xl/tables/table1.xml", "".getBytes()); + var file = new MockMultipartFile("file", "2026-price.xlsx", XLSX, zip(entries)); + + PriceBookFileStorage.Stored stored = storage.store(101L, file); + + assertThat(stored.originalName()).isEqualTo("2026-price.xlsx"); + assertThat(stored.objectKey()).matches("price-books/0/101/[0-9a-f-]{36}\\.xlsx"); + assertThat(stored.sha256()).hasSize(64); + assertThat(Files.isRegularFile(root.resolve(stored.objectKey()))).isTrue(); + } + + @Test + void rejectsFakeXlsxExtension() { + var file = new MockMultipartFile("file", "fake.xlsx", XLSX, "not-a-zip".getBytes()); + assertThatThrownBy(() -> storage.store(1L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("有效XLSX"); + } + + @Test + void rejectsOversizedFile() throws IOException { + ReflectionTestUtils.setField(storage, "maxSizeBytes", 8L); + var file = new MockMultipartFile("file", "large.xlsx", XLSX, zip(requiredEntries())); + assertThatThrownBy(() -> storage.store(1L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("超过允许大小"); + } + + @Test + void rejectsTraversalFileName() throws IOException { + var file = new MockMultipartFile("file", "../price.xlsx", XLSX, zip(requiredEntries())); + assertThatThrownBy(() -> storage.store(1L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("文件名不安全"); + } + + @Test + void rejectsWrongMimeType() throws IOException { + var file = new MockMultipartFile("file", "price.xlsx", "text/plain", zip(requiredEntries())); + assertThatThrownBy(() -> storage.store(1L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("类型不正确"); + } + + private static Map requiredEntries() { + Map entries = new LinkedHashMap<>(); + entries.put("[Content_Types].xml", "".getBytes()); + entries.put("xl/workbook.xml", "".getBytes()); + entries.put("xl/_rels/workbook.xml.rels", "".getBytes()); + return entries; + } + + private static byte[] zip(Map entries) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes)) { + for (Map.Entry entry : entries.entrySet()) { + zip.putNextEntry(new ZipEntry(entry.getKey())); + zip.write(entry.getValue()); + zip.closeEntry(); + } + } + return bytes.toByteArray(); + } +} diff --git a/file/src/test/java/com/baiyue/file/service/ProductImageStorageTest.java b/file/src/test/java/com/baiyue/file/service/ProductImageStorageTest.java new file mode 100644 index 000000000..07e6d728a --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/ProductImageStorageTest.java @@ -0,0 +1,62 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProductImageStorageTest { + @TempDir Path root; + + @Test + void storesValidatedImageUnderCustomerDirectoryAndReadsItBack() throws Exception { + ProductImageStorage storage = storage(); + byte[] content = png(); + + var stored = storage.store(10001L, + new MockMultipartFile("file", "../product.png", "image/png", content)); + + assertThat(stored.objectKey()).startsWith("product-images/10001/").endsWith(".png"); + assertThat(stored.originalName()).isEqualTo("product.png"); + assertThat(storage.read(stored.objectKey())).isEqualTo(content); + } + + @Test + void rejectsEmptyOversizedForgedAndMismatchedFiles() throws Exception { + ProductImageStorage storage = storage(); + assertThatThrownBy(() -> storage.store(10001L, + new MockMultipartFile("file", "empty.png", "image/png", new byte[0]))) + .isInstanceOf(BizException.class).hasMessageContaining("不能为空"); + assertThatThrownBy(() -> storage.store(10001L, + new MockMultipartFile("file", "large.png", "image/png", new byte[5 * 1024 * 1024 + 1]))) + .isInstanceOf(BizException.class).hasMessageContaining("5MB"); + assertThatThrownBy(() -> storage.store(10001L, + new MockMultipartFile("file", "fake.jpg", "image/jpeg", "not-an-image".getBytes()))) + .isInstanceOf(BizException.class).hasMessageContaining("有效图片"); + assertThatThrownBy(() -> storage.store(10001L, + new MockMultipartFile("file", "mismatch.jpg", "image/jpeg", png()))) + .isInstanceOf(BizException.class).hasMessageContaining("不一致"); + } + + private ProductImageStorage storage() { + ProductImageStorage storage = new ProductImageStorage(); + ReflectionTestUtils.setField(storage, "configuredRoot", root); + storage.initializeRoot(); + return storage; + } + + private static byte[] png() throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + ImageIO.write(new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB), "png", output); + return output.toByteArray(); + } +} diff --git a/firstleg/pom.xml b/firstleg/pom.xml index ea82d81ad..b651e8fee 100644 --- a/firstleg/pom.xml +++ b/firstleg/pom.xml @@ -18,6 +18,7 @@ org.springframework.cloudspring-cloud-starter-openfeign org.springframework.cloudspring-cloud-starter-loadbalancer com.alibaba.cloudspring-cloud-starter-stream-rocketmq + org.apache.poipoi-ooxml5.3.0 org.springframework.bootspring-boot-starter-actuator com.mysqlmysql-connector-jruntime org.springframework.bootspring-boot-starter-testtest diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java index 14d224680..3a095f79b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java @@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestHeader; import java.math.BigDecimal; +import java.util.List; @FeignClient(name = "order-service", contextId = "firstlegOrderCargoClient", url = "${baiyue.clients.order.url:}", path = "/internal/order/v1/cargoes") @@ -17,6 +18,12 @@ public interface OrderCargoClient { @PathVariable long orderId, @PathVariable long cargoId); + @GetMapping("/{tenantId}/{orderId}") + ApiResponse> listForCartonLabels( + @RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, + @PathVariable long orderId); + record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, Integer originalPackageCount, BigDecimal originalGrossWeightKg, BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn, @@ -29,4 +36,9 @@ public interface OrderCargoClient { originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null); } } + + record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, + Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm, + BigDecimal heightCm) { + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookFileClient.java new file mode 100644 index 000000000..803aa9d7d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookFileClient.java @@ -0,0 +1,37 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +/** File Service contract for immutable original price-book XLSX files. */ +@FeignClient(name = "file-service", contextId = "firstlegPriceBookFileClient", + url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/price-books") +public interface PriceBookFileClient { + @PostMapping(value = "/{businessId}", consumes = "multipart/form-data") + ApiResponse upload(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long businessId, + @RequestPart("file") MultipartFile file); + + @GetMapping("/{businessId}/{fileId}/metadata") + ApiResponse metadata(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long businessId, + @PathVariable long fileId); + + @GetMapping("/{businessId}/{fileId}/content") + ResponseEntity download(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long businessId, + @PathVariable long fileId); + + record FileMetadata(Long fileId, Long businessId, String fileName, String contentType, + Long sizeBytes, String sha256, String businessType) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookWarehouseClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookWarehouseClient.java new file mode 100644 index 000000000..708c9784d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookWarehouseClient.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; + +import java.util.List; + +@FeignClient(name = "warehouse-service", contextId = "firstlegPriceBookWarehouseClient", + url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouses") +public interface PriceBookWarehouseClient { + @GetMapping("/{tenantId}/{id}/active") + ApiResponse active(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable("tenantId") long tenantId, + @PathVariable("id") long id); + + record Detail(Warehouse warehouse, List
addresses) {} + record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName, + String warehouseType, String countryCode, String status) {} + record Address(String countryCode, String stateProvince, String city, String district, + String postalCode, String addressLine1, String addressLine2) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java new file mode 100644 index 000000000..c15672df4 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java @@ -0,0 +1,32 @@ +package com.baiyue.firstleg.client; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +import java.math.BigDecimal; +import java.util.List; + +/** Print Service contract for stateless inbound carton label rendering. */ +@FeignClient(name = "print-service", contextId = "firstlegPrintPdfClient", + url = "${baiyue.clients.print.url:}", path = "/internal/print/v1/pdfs") +public interface PrintPdfClient { + @PostMapping(value = "/inbound-marks", consumes = "application/json") + ResponseEntity generateInboundMarks( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader(value = "X-Request-Id", required = false) String requestId, + @RequestBody InboundMarkPdfRequest request); + + record InboundMarkPdfRequest(String templateCode, PageSpec pageSpec, + String downloadFileName, List
".getBytes()); + zip.closeEntry(); + } finally { + Files.deleteIfExists(valid); + } + return malformed; + } + + private static Path minimalWorkbook() throws IOException { + Path file = Files.createTempFile("price-parser-", ".xlsx"); + try (XSSFWorkbook workbook = new XSSFWorkbook()) { + for (int i = 0; i < STANDARD_SHEETS.size(); i++) { + String sheetName = STANDARD_SHEETS.get(i); + var sheet = workbook.createSheet(sheetName); + sheet.createRow(1).createCell(0).setCellValue("船名航次:DANIEL K. INOUYE V.100E 上海出,预计开船日期:2026年8月12日"); + boolean shifted = sheetName.equals("纽约EXX海铁"); + int base = shifted ? 1 : 0; + sheet.createRow(3).createCell(base).setCellValue("FBA仓库"); + sheet.getRow(3).createCell(base + 1).setCellValue("华东仓(义乌)"); + sheet.getRow(3).createCell(base + 3).setCellValue("华南仓(深圳)"); + var row = sheet.createRow(5); + row.createCell(base).setCellValue("ONT8/LGB8"); + row.createCell(base + 1).setCellValue(1790); + row.createCell(base + 2).setCellValue(10.2); + row.createCell(base + 3).setCellValue(10.7); + row.createCell(base + 4).setCellValue("20天左右"); + row.createCell(base + 5).setCellValue("周日全天"); + row.createCell(base + 6).setCellValue("MATSON 上海周三开"); + } + var parcel = workbook.createSheet("海派"); + parcel.createRow(4).createCell(2).setCellValue("MASTON 周三开"); + parcel.getRow(4).createCell(5).setCellValue("MASTON 周四开"); + parcel.getRow(4).createCell(8).setCellValue("合德 周四开"); + parcel.getRow(4).createCell(11).setCellValue("普船统配"); + parcel.createRow(5).createCell(2).setCellValue("船开后15天左右"); + parcel.createRow(8).createCell(0).setCellValue("美西"); + parcel.createRow(9).createCell(0).setCellValue("美中"); + parcel.createRow(10).createCell(0).setCellValue("美东"); + for (int row = 8; row <= 10; row++) { + for (int col = 2; col <= 13; col++) parcel.getRow(row).createCell(col).setCellValue(15 + col / 10.0); + } + parcel.createRow(13).createCell(2).setCellValue("MASTON 周三开"); + parcel.createRow(14).createCell(2).setCellValue("船开后15天左右"); + parcel.createRow(17).createCell(0).setCellValue("美西"); + parcel.createRow(18).createCell(0).setCellValue("美中"); + parcel.createRow(19).createCell(0).setCellValue("美东"); + for (int row = 17; row <= 19; row++) { + for (int col = 2; col <= 13; col++) parcel.getRow(row).createCell(col).setCellValue(16 + col / 10.0); + } + var quick = workbook.createSheet("快速查询"); + quick.createRow(4).createCell(0).setCellValue("列1"); + try (OutputStream output = Files.newOutputStream(file)) { workbook.write(output); } + } + return file; + } + + private static Path minimalStandardWorkbook(String destination, String east, String yiwuTax, String dongguanTax) throws IOException { + Path file = Files.createTempFile("price-parser-unknown-", ".xlsx"); + try (XSSFWorkbook workbook = new XSSFWorkbook()) { + var sheet = workbook.createSheet("美森正班海卡"); + sheet.createRow(3).createCell(0).setCellValue("FBA仓库"); + var row = sheet.createRow(5); + row.createCell(0).setCellValue(destination); + row.createCell(1).setCellValue(Double.parseDouble(east)); + row.createCell(2).setCellValue(Double.parseDouble(yiwuTax)); + row.createCell(3).setCellValue(Double.parseDouble(dongguanTax)); + try (OutputStream output = Files.newOutputStream(file)) { workbook.write(output); } + } + return file; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java new file mode 100644 index 000000000..1b9784e60 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java @@ -0,0 +1,65 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.Test; +import org.apache.ibatis.annotations.Delete; +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class CustomerDimensionConfigSqlContractTest { + @Test + void moduleMigrationDefinesUniqueCustomerConfigAndDefault() throws IOException { + String sql = new ClassPathResource("db/customer-dimension-config.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS fl_customer_dimension_config") + .contains("edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000") + .contains("initialization_key VARCHAR(128) NOT NULL") + .contains("UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id)"); + } + + @Test + void unifiedMigrationIncludesConfigAndFinalAssertion() throws IOException { + String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_customer_dimension_config") + .contains("客户尺寸计算配置表未创建") + .contains("uk_customer_dimension_config_tenant") + .contains("CREATE PROCEDURE unified_guard_dimension_config_permissions()") + .contains("id = 5102111 AND COALESCE(permission_code, '') <> 'firstleg:dimension-config:view'") + .contains("permission_code = 'firstleg:dimension-config:view' AND id <> 5102111") + .contains("id = 5102112 AND COALESCE(permission_code, '') <> 'firstleg:dimension-config:edit'") + .contains("permission_code = 'firstleg:dimension-config:edit' AND id <> 5102112") + .contains("(5102111, 0, 'PLATFORM', 'BUTTON', 5100010") + .contains("(5102112, 0, 'PLATFORM', 'BUTTON', 5100010") + .contains("r.role_code = 'PLATFORM_ADMIN'") + .contains("r.role_code = 'ORDER_OPERATOR'") + .contains("客户尺寸计算配置权限父节点企业客户不存在") + .contains("客户尺寸计算配置权限码、固定ID或父节点不正确") + .contains("平台管理员未获得客户尺寸计算配置读写权限") + .contains("订单运营角色的客户尺寸计算配置权限不是只读"); + } + + @Test + void compensationDeleteRequiresExactKeyDefaultValueAndUnmodifiedVersion() throws Exception { + Delete annotation = com.baiyue.firstleg.mapper.CustomerDimensionConfigMapper.class + .getDeclaredMethod("deleteUnchangedDefault", long.class, long.class, + String.class, java.math.BigDecimal.class) + .getAnnotation(Delete.class); + String sql = String.join(" ", annotation.value()); + + assertThat(sql) + .contains("tenant_id = #{tenantId}") + .contains("initialization_key = #{initializationKey}") + .contains("edge_addition_cm = #{edgeAdditionCm}") + .contains("version = 0"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCartonLabelSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCartonLabelSqlContractTest.java new file mode 100644 index 000000000..9452d8463 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCartonLabelSqlContractTest.java @@ -0,0 +1,35 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundCartonLabelSqlContractTest { + @Test + void baseSchemaAndExistingDatabaseMigrationKeepStableCartonKeys() throws Exception { + String schema = resource("db/phase2-inbound-schema.sql"); + String migration = resource("db/phase2-inbound-carton-label.sql"); + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); + + assertThat(schema).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label") + .contains("UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code)") + .contains("UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, carton_index)") + .contains("inbound_detail_id BIGINT UNSIGNED NOT NULL"); + assertThat(migration).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label") + .contains("固定二维码内容:入仓码#总箱数-箱下标") + .contains("idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id)"); + assertThat(unified).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label") + .contains("uk_carton_label_box_code") + .contains("uk_carton_label_index") + .contains("稳定箱号映射表未创建"); + } + + private String resource(String path) throws Exception { + return new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java index 36a47d521..637251088 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java @@ -5,6 +5,8 @@ import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; @@ -38,4 +40,27 @@ class InboundWorkflowSqlContractTest { .contains("SET warehouse_resource_tenant_id = 0") .contains("MODIFY COLUMN warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL"); } + + @Test + @DisplayName("单箱当前测量记录包含箱号、设备元数据和唯一约束") + void shouldPersistCurrentCartonMeasurementFields() throws Exception { + String schema = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String migration = new ClassPathResource("db/phase2-carton-measurement-current-record.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); + assertThat(schema) + .contains("box_code VARCHAR(128)") + .contains("machine_no VARCHAR(64)") + .contains("device_measured_at DATETIME(3)") + .contains("uk_measure_box_code"); + assertThat(migration) + .contains("phase2_add_carton_measurement_column") + .contains("uk_measure_box_code") + .contains("information_schema.STATISTICS"); + assertThat(unified) + .contains("CALL phase2_measure_add_column('fl_inbound_measurement_item', 'box_code'") + .contains("uk_measure_box_code") + .contains("单箱当前测量字段未完整创建"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java new file mode 100644 index 000000000..3ef7e2a0f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java @@ -0,0 +1,63 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("船期价格表SQL契约") +class PriceBookSqlContractTest { + @Test + @DisplayName("基线包含整表版本、固定渠道、起运仓别名和两类价格明细") + void shouldProvidePriceBookTables() throws Exception { + String schema = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String migration = new ClassPathResource("db/phase2-fba-pricing-migration.sql") + .getContentAsString(StandardCharsets.UTF_8); + String channelTable = schema.substring( + schema.indexOf("CREATE TABLE IF NOT EXISTS fl_price_book_channel"), + schema.indexOf("ENGINE=InnoDB COMMENT='价格版本渠道及船期快照'") + ); + String rateTable = schema.substring( + schema.indexOf("CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate"), + schema.indexOf("ENGINE=InnoDB COMMENT='船司FBA目的仓价格版本'") + ); + 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") + .contains("price_model VARCHAR(32)") + .contains("pricing_tax_mode VARCHAR(16)") + .contains("source_sheet_name VARCHAR(128)") + .contains("source_row_number INT UNSIGNED") + .contains("destination_type VARCHAR(16)") + .contains("destination_warehouse_id BIGINT UNSIGNED") + .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'") + .contains("deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记'") + .contains("version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'"); + assertThat(rateTable) + .contains("status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE'") + .contains("PENDING_CONFIRMATION、ACTIVE或SUPERSEDED"); + assertThat(migration) + .doesNotContain("fl_price_book_version") + .doesNotContain("'pricing_tax_mode'") + .doesNotContain("'price_book_version_id'"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java new file mode 100644 index 000000000..63e3d55df --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java @@ -0,0 +1,196 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +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.entity.InboundMeasurementEntity; +import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.repository.InboundMeasurementRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mock; + +class CartonMeasurementServiceTest { + private final InboundMeasurementRepository repository = mock(InboundMeasurementRepository.class); + @SuppressWarnings("unchecked") + private final ObjectProvider resolverProvider = mock(ObjectProvider.class); + private final CartonLabelBindingResolver resolver = mock(CartonLabelBindingResolver.class); + private final MeasurementImageClient images = mock(MeasurementImageClient.class); + private final TransactionTemplate transactions = mock(TransactionTemplate.class); + private CartonMeasurementService service; + private final AtomicReference insertedItem = new AtomicReference<>(); + + @BeforeEach + void setUp() { + service = new CartonMeasurementService(repository, resolverProvider, images, transactions); + 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(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( + 99001L, "measure.jpg", "image/jpeg", 10L, "NOT_REQUIRED", null, null, null, null))); + when(transactions.execute(any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(mock(org.springframework.transaction.TransactionStatus.class)); + }); + doAnswer(invocation -> { + insertedItem.set(invocation.getArgument(0)); + return null; + }).when(repository).insertItem(any()); + } + + @Test + void firstScanCreatesOneRecordWithConfirmedCountOne() { + InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0); + when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null); + when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement); + when(repository.listItems(10001L, 30001L)).thenAnswer(invocation -> List.of(insertedItem.get())); + when(repository.updateMeasurement(measurement)).thenReturn(true); + + CartonMeasurementResult result = service.upsert(10001L, 90001L, command()); + + assertThat(result.created()).isTrue(); + 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"); + } + + @Test + void repeatedScanReplacesDeviceValuesButPreservesManualCountAndOldImageIsDeleted() { + InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 4); + InboundMeasurementItemEntity existing = item(40001L, 30001L, 7, 88001L); + when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(existing); + when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement); + when(repository.updateItem(existing)).thenReturn(true); + when(repository.listItems(10001L, 30001L)).thenReturn(List.of(existing)); + when(repository.updateMeasurement(measurement)).thenReturn(true); + + CartonMeasurementResult result = service.upsert(10001L, 90001L, command()); + + assertThat(result.created()).isFalse(); + assertThat(result.confirmedPackageCount()).isEqualTo(7); + assertThat(existing.getLengthCm()).isEqualByComparingTo("60.100"); + assertThat(existing.getSourceImageFileId()).isEqualTo(99001L); + verify(images).delete("internal-token", 90001L, 10001L, 70001L, 88001L); + } + + @Test + void submittedInboundRejectsDeviceBeforeUploadingImage() { + when(repository.findInbound(10001L, 70001L)).thenReturn(inbound("MEASURE_PENDING")); + + assertThatThrownBy(() -> service.upsert(10001L, 90001L, command())) + .isInstanceOf(BizException.class).hasMessageContaining("不能新增或覆盖"); + + verify(images, never()).upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any()); + } + + @Test + void platformSimulationRejectsBoxCodeFromAnotherInboundBeforeUploadingImage() { + assertThatThrownBy(() -> service.upsertForInbound(10001L, 90001L, 70002L, command())) + .isInstanceOf(BizException.class) + .hasMessageContaining("箱号不属于当前入库单"); + + verify(images, never()).upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any()); + } + + @Test + void confirmedPackageCountUsesMeasurementOptimisticVersion() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 2); + InboundMeasurementItemEntity item = item(40001L, 30001L, 1, 99001L); + 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); + + CartonMeasurementResult result = service.updateConfirmedPackageCount( + 10001L, 70001L, 40001L, 2, 5); + + assertThat(result.confirmedPackageCount()).isEqualTo(5); + assertThat(item.getPackageCount()).isEqualTo(5); + verify(repository).updateMeasurement(measurement); + } + + @Test + void confirmedPackageCountCanBeCorrectedAfterSubmissionWithoutChangingOriginalPackageCount() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2); + measurement.setTotalPackageCount(1); + measurement.setTotalVolumeCbm(new BigDecimal("0.0600")); + measurement.setVolumeWeightKg(new BigDecimal("10.020")); + InboundMeasurementItemEntity item = item(40001L, 30001L, 1, 99001L); + when(repository.findInbound(10001L, 70001L)).thenReturn(inbound); + 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); + when(repository.updateInbound(inbound)).thenReturn(true); + + service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 5); + + assertThat(inbound.getTotalPackageCount()).isEqualTo(5); + assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.3000"); + assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("50.100"); + verify(repository).updateInbound(inbound); + } + + private CartonMeasurementCommand command() { + return new CartonMeasurementCommand("RK-001#5-1", new BigDecimal("60.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())); + } + + private InboundOrderEntity inbound(String status) { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(70001L); inbound.setTenantId(10001L); inbound.setProcessStatus(status); + inbound.setTotalPackageCount(5); inbound.setVersion(0); + return inbound; + } + + private InboundMeasurementEntity measurement(long id, String status, int version) { + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(id); measurement.setTenantId(10001L); measurement.setInboundId(70001L); + measurement.setStatus(status); measurement.setVersion(version); + return measurement; + } + + private InboundMeasurementItemEntity item(long id, long measurementId, int packageCount, long imageId) { + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setId(id); item.setTenantId(10001L); item.setMeasurementId(measurementId); + item.setBoxCode("RK-001#5-1"); item.setPackageCount(packageCount); + item.setLengthCm(new BigDecimal("50")); item.setWidthCm(new BigDecimal("40")); + item.setHeightCm(new BigDecimal("30")); item.setUnitWeightKg(new BigDecimal("10")); + item.setSourceImageFileId(imageId); item.setItemNo("B1"); item.setBoxSpecification("50x40x30cm"); + return item; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java new file mode 100644 index 000000000..f8d28d1c8 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java @@ -0,0 +1,109 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeResponse; +import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest; +import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity; +import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(CustomerDimensionConfigService.class) +class CustomerDimensionConfigServiceTest { + @MockBean + private CustomerDimensionConfigRepository repository; + @Resource + private CustomerDimensionConfigService service; + + @Test + void initializesTwoCentimeterDefaultExactlyOnce() { + CustomerDimensionConfigEntity persisted = config(91L, 8L, "2.000", 0, "registration-1"); + when(repository.find(8L)).thenReturn(null, persisted); + when(repository.insertDefault(anyLong(), eq(8L), eq(new BigDecimal("2.000")), + eq("默认每侧加宽 2cm"), eq("registration-1"), eq(100L))).thenReturn(true); + + InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1"); + + assertThat(result.createdByThisInitialization()).isTrue(); + assertThat(result.configId()).isEqualTo(91L); + } + + @Test + void retryAfterLostResponseKeepsCreationOwnershipForSameKey() { + when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 0, "registration-1")); + + InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1"); + + assertThat(result).isEqualTo(new InitializeResponse(91L, true)); + verify(repository, never()).insertDefault(anyLong(), anyLong(), + eq(new BigDecimal("2.000")), eq("默认每侧加宽 2cm"), + eq("registration-1"), anyLong()); + } + + @Test + void differentInitializationKeyDoesNotTakeOwnershipOrOverwrite() { + when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 0, "registration-1")); + + InitializeResponse result = service.initializeDefault(8L, 100L, "registration-2"); + + assertThat(result).isEqualTo(new InitializeResponse(91L, false)); + verify(repository, never()).insertDefault(anyLong(), anyLong(), + eq(new BigDecimal("2.000")), eq("默认每侧加宽 2cm"), + eq("registration-2"), anyLong()); + } + + @Test + void rejectsStaleUpdateVersion() { + when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 3, "registration-1")); + + assertThatThrownBy(() -> service.update(8L, 100L, + new UpdateRequest(new BigDecimal("2.500"), "新配置", 2))) + .isInstanceOf(BizException.class) + .hasMessageContaining("刷新后重试"); + verify(repository, never()).update(org.mockito.ArgumentMatchers.any()); + } + + @Test + void compensationUsesTenantKeyDefaultValueAndUnmodifiedVersion() { + when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 0, "registration-1")); + + service.compensateDefault(8L, "registration-1"); + + verify(repository).deleteUnchangedDefault( + 8L, 91L, "registration-1", new BigDecimal("2.000")); + } + + @Test + void editedConfigIsNotCompensatedByDelayedRollback() { + when(repository.find(8L)).thenReturn(config(91L, 8L, "2.500", 1, "registration-1")); + + service.compensateDefault(8L, "registration-1"); + + verify(repository).deleteUnchangedDefault( + 8L, 91L, "registration-1", new BigDecimal("2.000")); + } + + private static CustomerDimensionConfigEntity config(long id, long tenantId, + String edgeAddition, int version, + String initializationKey) { + CustomerDimensionConfigEntity value = new CustomerDimensionConfigEntity(); + value.setId(id); + value.setTenantId(tenantId); + value.setEdgeAdditionCm(new BigDecimal(edgeAddition)); + value.setVersion(version); + value.setInitializationKey(initializationKey); + return value; + } +} 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 952aa768f..6edd458d7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -2,14 +2,16 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.domain.PriceModel; +import com.baiyue.firstleg.domain.PricingTaxMode; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; -import com.baiyue.firstleg.entity.CarrierFbaRateEntity; -import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; import com.baiyue.firstleg.entity.InboundFeeEntity; import com.baiyue.firstleg.entity.InboundFeeItemEntity; import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; +import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; import com.baiyue.firstleg.repository.FbaPricingRepository; +import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -24,7 +26,6 @@ import java.time.Clock; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; -import java.util.List; import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; @@ -33,7 +34,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -48,6 +48,7 @@ class FbaPricingServiceTest { private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 3, 10, 0); @MockBean private FbaPricingRepository repository; @MockBean private InboundFeeDetailService feeDetails; + @MockBean private ActivePriceBookEstimator priceBookEstimator; @Resource private FbaPricingService service; @Test @@ -63,15 +64,15 @@ class FbaPricingServiceTest { } @Test - @DisplayName("包税KG按配置系数取体积重较大值并按规则向上舍入") - void calculatesTaxIncludedKgWithConfiguredFactorAndPrecision() { + @DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商") + void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() { readyRepository(); InboundPricingEntity inbound = inbound("100.000", "1.0001"); - CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING"); - CarrierFbaRateEntity rate = rate("KG", "2.345678", "10.00"); + inbound.setCarrierId(null); + inbound.setCarrierName(null); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule)); - when(repository.matchRates(inbound, "KG", NOW)).thenReturn(List.of(rate)); + when(priceBookEstimator.estimate(any())).thenReturn( + estimate("KG", "167.017", "2.345678", "391.77", "10.00")); FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-1"); @@ -79,19 +80,26 @@ class FbaPricingServiceTest { assertThat(response.chargeableQuantity()).isEqualByComparingTo("167.017"); assertThat(response.subtotalAmount()).isEqualByComparingTo("391.77"); assertThat(response.totalAmount()).isEqualByComparingTo("401.77"); + assertThat(response.priceBookVersionId()).isEqualTo(701L); + assertThat(response.pricingTaxMode()).isEqualTo("TAX_INCLUDED"); assertThat(response.nextProcessStatus()).isEqualTo("FEE_CONFIRMING"); + ArgumentCaptor fee = ArgumentCaptor.forClass(InboundFeeEntity.class); + verify(repository).insertFee(fee.capture()); + assertThat(fee.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION"); + assertThat(fee.getValue().getPricingTaxMode()).isEqualTo("TAX_INCLUDED"); + assertThat(fee.getValue().getChargeRuleId()).isNull(); + assertThat(fee.getValue().getCarrierId()).isNull(); } @Test - @DisplayName("不包税CBM按配置系数折算且精度边界向上取整") - void calculatesTaxExcludedCbmAtRoundingBoundary() { + @DisplayName("不包税CBM使用生效价格表返回的计费量") + void calculatesTaxExcludedCbmFromActivePriceBook() { readyRepository(); InboundPricingEntity inbound = inbound("363.001", "0.5000"); - CustomerChargeRuleEntity rule = rule("CBM", "363.000000", 4, "CEILING"); - CarrierFbaRateEntity rate = rate("CBM", "100.000000", "0.00"); + inbound.setPricingTaxMode("TAX_EXCLUDED"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule)); - when(repository.matchRates(inbound, "CBM", NOW)).thenReturn(List.of(rate)); + when(priceBookEstimator.estimate(any())).thenReturn( + estimate("CBM", "1.0001", "100.000000", "100.01", "0.00")); FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-2"); @@ -109,8 +117,8 @@ class FbaPricingServiceTest { CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING"); CarrierFbaRateEntity rate = rate("KG", "2.000000", "0.00"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule)); - when(repository.matchRates(inbound, "KG", NOW)).thenReturn(List.of(rate)); + when(priceBookEstimator.estimate(any())).thenReturn( + estimate("KG", "167.000", "2.000000", "334.00", "0.00")); FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "wait-split"); @@ -156,26 +164,21 @@ class FbaPricingServiceTest { assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-3")) .isInstanceOf(BizException.class) .hasMessageContaining("审核通过"); - verify(repository, never()).matchRules(any(), any()); + verify(priceBookEstimator, never()).estimate(any()); } @Test - @DisplayName("无客户规则和多客户规则都明确拒绝") - void rejectsMissingAndAmbiguousRules() { + @DisplayName("当前生效价格表无匹配价格时明确拒绝") + void rejectsMissingActivePriceBookRate() { readyRepository(); InboundPricingEntity inbound = inbound("10", "1"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(repository.matchRules(inbound, NOW)).thenReturn(List.of()); - assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "none")) - .isInstanceOf(BizException.class).hasMessageContaining("未匹配到有效客户计费规则"); + when(priceBookEstimator.estimate(any())).thenThrow( + new BizException(ResultCode.NOT_FOUND, "当前条件没有匹配价格")); - org.mockito.Mockito.reset(repository); - readyRepository(); - when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(repository.matchRules(inbound, NOW)).thenReturn(List.of( - rule("KG", "167", 3, "CEILING"), rule("KG", "168", 3, "CEILING"))); - assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "many")) - .isInstanceOf(BizException.class).hasMessageContaining("多条有效客户计费规则"); + assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "none")) + .isInstanceOf(BizException.class).hasMessageContaining("没有匹配价格"); + verify(repository, never()).insertFee(any()); } @Test @@ -299,29 +302,25 @@ class FbaPricingServiceTest { value.setProcessStatus("WAIT_PRICING"); value.setMeasureReviewStatus("APPROVED"); value.setFeeStatus("UNPRICED"); value.setCustomerId(11L); value.setCustomerName("客户A"); value.setWarehouseId(21L); value.setWarehouseName("义乌仓"); value.setBusinessType("FBA_SEA"); - value.setChannelCode("SEA-TAX"); value.setCarrierId(31L); value.setCarrierName("船司A"); - value.setTaxMode("TAX_INCLUDED"); value.setDestinationType("FBA"); value.setDestinationCode("ONT8"); + value.setChannelCode("MATSON_TRUCK"); value.setCarrierId(31L); value.setCarrierName("船司A"); + value.setTaxMode("BUY_DECLARATION"); value.setPricingTaxMode("TAX_INCLUDED"); + value.setDestinationType("FBA"); value.setDestinationCode("ONT8"); value.setActualGrossWeightKg(new BigDecimal(weight)); value.setActualVolumeCbm(new BigDecimal(volume)); return value; } - private CustomerChargeRuleEntity rule(String mode, String factor, int scale, String rounding) { - CustomerChargeRuleEntity value = new CustomerChargeRuleEntity(); - value.setId(101L); value.setRuleCode("CR-001"); value.setRuleVersion(1); - value.setChargeMode(mode); value.setConversionFactor(new BigDecimal(factor)); - value.setRoundingScale(scale); value.setRoundingMode(rounding); value.setCurrency("USD"); - return value; - } - - private CarrierFbaRateEntity rate(String unit, String price, String additional) { - CarrierFbaRateEntity value = new CarrierFbaRateEntity(); - value.setId(201L); value.setRateCode("RT-001"); value.setRateVersion(1); - value.setOriginWarehouseId(21L); value.setOriginWarehouseName("义乌仓"); - value.setChannelCode("SEA-TAX"); value.setCarrierId(31L); value.setCarrierName("船司A"); - value.setDestinationCode("ONT8"); value.setChargeUnit(unit); value.setUnitPrice(new BigDecimal(price)); - value.setCurrency("USD"); value.setAdditionalFee(new BigDecimal(additional)); - value.setEffectiveFrom(NOW.minusDays(1)); value.setEffectiveTo(NOW.plusDays(1)); - return value; + private ActivePriceBookEstimator.Estimate estimate(String unit, String quantity, String price, + String amount, String additional) { + RateRow rate = new RateRow(); + rate.setId(201L); rate.setPriceBookVersionId(701L); rate.setRateCode("PB-7-1"); rate.setRateVersion(1); + rate.setOriginWarehouseId(21L); rate.setOriginWarehouseName("义乌仓"); rate.setChannelCode("MATSON_TRUCK"); + rate.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name()); rate.setDestinationType("FBA"); + rate.setDestinationCode("ONT8"); rate.setPricingTaxMode("TAX_INCLUDED"); rate.setChargeUnit(unit); + rate.setUnitPrice(new BigDecimal(price)); rate.setCurrency("USD"); + rate.setAdditionalFee(new BigDecimal(additional)); rate.setEffectiveFrom(NOW.minusDays(1)); + return new ActivePriceBookEstimator.Estimate(701L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE, + PricingTaxMode.TAX_INCLUDED, rate, new BigDecimal(quantity), unit, + new BigDecimal(price), new BigDecimal(amount), "USD"); } private InboundPricingIdempotencyEntity completed(String operation, long feeId) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java new file mode 100644 index 000000000..83d334252 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java @@ -0,0 +1,89 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.PrintPdfClient; +import com.baiyue.firstleg.dto.InboundCartonLabels; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +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.verify; +import static org.mockito.Mockito.when; + +class InboundCartonLabelPrintServiceTest { + private final InboundCartonLabelService labels = mock(InboundCartonLabelService.class); + private final PrintPdfClient print = mock(PrintPdfClient.class); + private InboundCartonLabelPrintService service; + + @BeforeEach + void setUp() { + service = new InboundCartonLabelPrintService(labels, print, new ObjectMapper()); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + } + + @Test + void forwardsStableLabelsAndOnlySafePdfHeaders() { + when(labels.list(10001L, 70001L, List.of(1))).thenReturn(batch()); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_PDF); + headers.setContentDispositionFormData("attachment", "labels.pdf"); + headers.set("X-Print-Page-Count", "1"); + headers.set("X-Internal-Service-Token", "must-not-leak"); + when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any())) + .thenReturn(new ResponseEntity<>(new byte[] {1, 2, 3}, headers, + org.springframework.http.HttpStatus.OK)); + + ResponseEntity response = service.generate(10001L, 70001L, List.of(1), "request-1"); + + ArgumentCaptor request = + ArgumentCaptor.forClass(PrintPdfClient.InboundMarkPdfRequest.class); + verify(print).generateInboundMarks(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.eq("request-1"), request.capture()); + assertThat(request.getValue().downloadFileName()).isEqualTo("PCHK2608040174-19件-箱唛.pdf"); + assertThat(request.getValue().labels()).singleElement().satisfies(label -> { + assertThat(label.qrContent()).isEqualTo("PCHK2608040174#19-1"); + assertThat(label.cartonText()).isEqualTo("CTNS:19-1"); + }); + assertThat(response.getBody()).containsExactly(1, 2, 3); + assertThat(response.getHeaders().getFirst("X-Print-Page-Count")).isEqualTo("1"); + assertThat(response.getHeaders()).doesNotContainKey("X-Internal-Service-Token"); + } + + @Test + void convertsPrintJsonBusinessFailureInsteadOfReturningItAsPdf() throws Exception { + when(labels.list(10001L, 70001L, null)).thenReturn(batch()); + byte[] error = "{\"code\":\"A0422\",\"message\":\"标签字段超出模板限制\"," + .concat("\"data\":null,\"requestId\":\"req-2\",\"traceId\":\"trace-2\"}") + .getBytes(java.nio.charset.StandardCharsets.UTF_8); + when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any())) + .thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(error)); + + assertThatThrownBy(() -> service.generate(10001L, 70001L, null, null)) + .isInstanceOf(BizException.class) + .hasMessageContaining("标签字段超出模板限制") + .extracting(value -> ((BizException) value).getResultCode()) + .isEqualTo(ResultCode.VALIDATION_FAILED); + } + + private InboundCartonLabels.Batch batch() { + InboundCartonLabels.Label label = new InboundCartonLabels.Label( + 1L, 2L, 1, "音箱", 19, "PCHK2608040174#19-1", 19, 1, + "50x40x30cm", new BigDecimal("50"), new BigDecimal("40"), new BigDecimal("30"), + "PCHK2608040174", "MCC1", "CTNS:19-1", "洛杉矶普船海卡"); + return new InboundCartonLabels.Batch(70001L, 80001L, "PCHK2608040174", 19, List.of(label)); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java new file mode 100644 index 000000000..ac3948572 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java @@ -0,0 +1,189 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.OrderCargoClient; +import com.baiyue.firstleg.entity.InboundCartonLabelEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.repository.InboundCartonLabelRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundCartonLabelServiceTest { + @Mock private InboundCartonLabelRepository repository; + @Mock private OrderCargoClient orderCargoClient; + private InboundCartonLabelService service; + + @BeforeEach + void setUp() { + service = new InboundCartonLabelService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "orderCargoClient", orderCargoClient); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + } + + @Test + void firstAllocationUsesCargoOrderAndFreezesEveryPhysicalCarton() { + InboundOrderEntity inbound = inbound(); + when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound); + when(repository.findByInbound(10001L, 70001L)).thenReturn(new ArrayList<>()); + var second = cargo(80002L, 2, 2, "50", "30", "20"); + var first = cargo(80001L, 1, 3, "60", "40", "30"); + when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L)) + .thenReturn(ApiResponse.success(List.of(second, first))); + when(repository.initializeOriginalPackageCount(10001L, 70001L, 5)).thenReturn(true); + List inserted = new ArrayList<>(); + org.mockito.Mockito.doAnswer(invocation -> { + inserted.add(invocation.getArgument(0)); + return null; + }).when(repository).insert(any()); + + var batch = service.getOrCreate(10001L, 70001L); + + assertThat(batch.totalCount()).isEqualTo(5); + assertThat(batch.labels()).extracting(label -> label.boxCode()) + .containsExactly("PCHK2608040174#5-1", "PCHK2608040174#5-2", "PCHK2608040174#5-3", + "PCHK2608040174#5-4", "PCHK2608040174#5-5"); + assertThat(batch.labels()).extracting(label -> label.inboundDetailId()) + .containsExactly(80001L, 80001L, 80001L, 80002L, 80002L); + assertThat(batch.labels().get(0).boxSpecification()).isEqualTo("60x40x30cm"); + assertThat(batch.labels().get(0).cartonText()).isEqualTo("CTNS:5-1"); + assertThat(inbound.getTotalPackageCount()).isEqualTo(5); + assertThat(inserted).hasSize(5); + verify(repository).initializeOriginalPackageCount(10001L, 70001L, 5); + } + + @Test + void repeatedAndPartialPrintsReadFrozenMappingWithoutReallocation() { + InboundOrderEntity inbound = inbound(); + inbound.setTotalPackageCount(5); + List existing = labels(inbound, 5); + when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound); + when(repository.findByInbound(10001L, 70001L)).thenReturn(existing); + + var batch = service.list(10001L, 70001L, List.of(5, 2, 2)); + + assertThat(batch.totalCount()).isEqualTo(5); + assertThat(batch.labels()).extracting(label -> label.cartonIndex()).containsExactly(2, 5); + verify(orderCargoClient, never()).listForCartonLabels(any(String.class), + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong()); + verify(repository, never()).insert(any()); + } + + @Test + void refusesFrozenMappingWhenOriginalPackageCountDoesNotMatch() { + InboundOrderEntity inbound = inbound(); + inbound.setTotalPackageCount(4); + when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound); + when(repository.findByInbound(10001L, 70001L)).thenReturn(labels(inbound, 5)); + + assertThatThrownBy(() -> service.getOrCreate(10001L, 70001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("订单总箱数与已冻结箱号数量不一致"); + + verify(repository, never()).initializeOriginalPackageCount( + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyInt()); + } + + @Test + void resolvesBoxCodeInsideCustomerBoundary() { + InboundCartonLabelEntity label = labels(inbound(), 1).get(0); + when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label); + + var resolved = service.resolveByBoxCode(10001L, " PCHK2608040174#1-1 "); + + assertThat(resolved.inboundId()).isEqualTo(70001L); + assertThat(resolved.inboundDetailId()).isEqualTo(80001L); + assertThat(resolved.inboundCode()).isEqualTo("PCHK2608040174"); + assertThat(resolved.cartonIndex()).isEqualTo(1); + assertThat(resolved.totalCartons()).isEqualTo(1); + assertThat(resolved.boxSpecification()).isEqualTo("60x40x30cm"); + verify(repository).findByBoxCode(10001L, "PCHK2608040174#1-1"); + } + + @Test + void exposesDetailedResolutionForFirstlegConsumers() { + InboundCartonLabelEntity label = labels(inbound(), 1).get(0); + when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label); + + var resolved = service.resolveDetailsByBoxCode(10001L, "PCHK2608040174#1-1"); + + assertThat(resolved.orderId()).isEqualTo(60001L); + assertThat(resolved.boxCode()).isEqualTo("PCHK2608040174#1-1"); + assertThat(resolved.cargoItemNo()).isEqualTo(1); + } + + @Test + void refusesFirstAllocationAfterMeasurementSubmission() { + InboundOrderEntity inbound = inbound(); + inbound.setProcessStatus("MEASURE_PENDING"); + when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound); + when(repository.findByInbound(10001L, 70001L)).thenReturn(List.of()); + + assertThatThrownBy(() -> service.getOrCreate(10001L, 70001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("不允许首次生成箱号"); + verify(orderCargoClient, never()).listForCartonLabels(any(String.class), + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong()); + } + + private InboundOrderEntity inbound() { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(70001L); + inbound.setTenantId(10001L); + inbound.setOrderId(60001L); + inbound.setInboundCode("PCHK2608040174"); + inbound.setProcessStatus("WAIT_MEASURE"); + inbound.setWarehouseName("MCC1"); + inbound.setChannelName("洛杉矶普船海卡"); + return inbound; + } + + private OrderCargoClient.CargoLabelBinding cargo(long id, int itemNo, int count, + String length, String width, String height) { + return new OrderCargoClient.CargoLabelBinding(id, 60001L, itemNo, "货物" + itemNo, count, + new BigDecimal(length), new BigDecimal(width), new BigDecimal(height)); + } + + private List labels(InboundOrderEntity inbound, int total) { + List values = new ArrayList<>(); + for (int index = 1; index <= total; index++) { + InboundCartonLabelEntity label = new InboundCartonLabelEntity(); + label.setId((long) index); + label.setTenantId(inbound.getTenantId()); + label.setInboundId(inbound.getId()); + label.setOrderId(inbound.getOrderId()); + label.setInboundDetailId(80001L); + label.setCargoItemNo(1); + label.setCargoName("货物1"); + label.setDetailPackageCount(total); + label.setInboundCode(inbound.getInboundCode()); + label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index); + label.setTotalCartonCount(total); + label.setCartonIndex(index); + label.setBoxSpecification("60x40x30cm"); + label.setLengthCm(new BigDecimal("60")); + label.setWidthCm(new BigDecimal("40")); + label.setHeightCm(new BigDecimal("30")); + values.add(label); + } + return values; + } +} 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 068c70a34..f49a4c2f3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -16,6 +16,8 @@ import jakarta.annotation.Resource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.mockito.ArgumentCaptor; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; @@ -25,11 +27,13 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -86,6 +90,24 @@ class InboundMeasurementServiceTest { verify(repository, never()).updateInbound(any()); } + @Test + void creatingWeightResultNormalizesProviderConfidenceAtBusinessBoundary() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + + InboundMeasurementResponses.WeightResult result = service.createWeightResult( + 1L, 10L, 9L, + new InboundMeasurementRequests.CreateWeightResultRequest( + 120L, "FILE_WEIGHT_OCR", "285.5", new BigDecimal("285.500"), "KG", + new BigDecimal("0.5097191334"))); + + ArgumentCaptor saved = + ArgumentCaptor.forClass(InboundWeightResultEntity.class); + verify(repository).insertWeight(saved.capture()); + assertThat(saved.getValue().getConfidence()).isEqualByComparingTo("0.50972"); + assertThat(result.confidence()).isEqualByComparingTo("0.50972"); + } + @Test void confirmingLbWeightConvertsToKgAndRecalculatesOnlyConfirmedImages() { InboundOrderEntity inbound = inbound("WAIT_MEASURE"); @@ -100,7 +122,7 @@ class InboundMeasurementServiceTest { InboundMeasurementResponses.WeightResult response = service.confirmWeightResult( 1L, 10L, 20L, 9L, "操作员", - new InboundMeasurementRequests.ConfirmWeightResultRequest(0)); + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)); assertThat(response.finalWeightKg()).isEqualByComparingTo("4.536"); assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("6.536"); @@ -108,6 +130,238 @@ class InboundMeasurementServiceTest { verify(repository).insertRevision(any(InboundWeightRevisionEntity.class)); } + @ParameterizedTest + @CsvSource({ + "kg,2.000", + "KGS,2.000", + "lb,0.907", + "LBS,0.907", + "' lbs ',0.907" + }) + void confirmingAcceptsSupportedUnitAliases(String unit, String expectedKg) { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "1.000", "KG", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(repository.updateInbound(inbound)).thenReturn(true); + + InboundMeasurementResponses.WeightResult response = service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest( + 0, new BigDecimal("2.000"), unit, "单位边界测试")); + + assertThat(response.adjustedWeightUnit()).isEqualTo( + unit.trim().toUpperCase().startsWith("LB") ? "LB" : "KG"); + assertThat(response.finalWeightKg()).isEqualByComparingTo(expectedKg); + assertThat(response.version()).isEqualTo(2); + } + + @Test + void confirmingAcceptsSmallestRepresentableKgWeight() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "0.001", "KG", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(repository.updateInbound(inbound)).thenReturn(true); + + InboundMeasurementResponses.WeightResult response = service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)); + + assertThat(response.finalWeightKg()).isEqualByComparingTo("0.001"); + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("0.001"); + } + + @Test + void confirmingRejectsLbWeightThatRoundsToZeroKgBeforePersisting() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "1.000", "KG", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + + assertThatThrownBy(() -> service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest( + 0, new BigDecimal("0.001"), "LB", "极小重量边界测试"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("换算后重量不能小于 0.001 KG"); + + verify(repository, never()).updateWeight(any()); + verify(repository, never()).insertRevision(any()); + verify(repository, never()).updateInbound(any()); + } + + @Test + void confirmingRejectsOcrLbWeightThatRoundsToZeroKg() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "0.001", "LB", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + + assertThatThrownBy(() -> service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("换算后重量不能小于 0.001 KG"); + + verify(repository, never()).updateWeight(any()); + verify(repository, never()).insertRevision(any()); + verify(repository, never()).updateInbound(any()); + } + + @Test + void confirmingRejectsUnsupportedUnitBeforePersisting() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + + assertThatThrownBy(() -> service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest( + 0, new BigDecimal("286.125"), "G", "错误单位测试"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("重量单位仅支持 KG 或 LB"); + + verify(repository, never()).updateWeight(any()); + verify(repository, never()).insertRevision(any()); + } + + @Test + void confirmingAdjustedWeightWritesCorrectionAndConfirmationInOneTransaction() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(repository.updateInbound(inbound)).thenReturn(true); + + InboundMeasurementResponses.WeightResult response = service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest( + 0, new BigDecimal("286.125"), "kg", "OCR 原值 285.500 KG,人工确认 286.125 KG")); + + assertThat(response.adjustedWeightValue()).isEqualByComparingTo("286.125"); + assertThat(response.finalWeightKg()).isEqualByComparingTo("286.125"); + assertThat(response.recognitionStatus()).isEqualTo("CONFIRMED"); + assertThat(response.version()).isEqualTo(2); + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("286.125"); + verify(repository, times(2)).updateWeight(weight); + ArgumentCaptor revisions = + ArgumentCaptor.forClass(InboundWeightRevisionEntity.class); + verify(repository, times(2)).insertRevision(revisions.capture()); + assertThat(revisions.getAllValues()).extracting( + InboundWeightRevisionEntity::getRevisionNo, + InboundWeightRevisionEntity::getAction) + .containsExactly(tuple(1, "CORRECT"), tuple(2, "CONFIRM")); + assertThat(revisions.getAllValues().get(0).getReason()) + .isEqualTo("OCR 原值 285.500 KG,人工确认 286.125 KG"); + } + + @Test + void confirmingRejectsPartialAdjustmentBeforePersisting() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + + assertThatThrownBy(() -> service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest( + 0, new BigDecimal("286.125"), null, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("有效重量、单位和修改原因"); + + verify(repository, never()).updateWeight(any()); + verify(repository, never()).insertRevision(any()); + } + + @Test + void confirmingRejectsEveryPartialAdjustmentCombinationBeforePersisting() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + List partialRequests = List.of( + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, "KG", null), + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, "人工修正"), + new InboundMeasurementRequests.ConfirmWeightResultRequest( + 0, new BigDecimal("286.125"), " ", " ") + ); + + partialRequests.forEach(request -> assertThatThrownBy(() -> service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", request)) + .isInstanceOf(BizException.class) + .hasMessageContaining("有效重量、单位和修改原因")); + + verify(repository, never()).updateWeight(any()); + verify(repository, never()).insertRevision(any()); + verify(repository, never()).updateInbound(any()); + } + + @Test + void confirmingAnAlreadyConfirmedWeightWithCurrentVersionIsIdempotent() { + InboundOrderEntity inbound = inbound("MEASURING"); + InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2); + weight.setFinalWeightKg(new BigDecimal("285.500")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + + InboundMeasurementResponses.WeightResult response = service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(2, null, null, null)); + + assertThat(response.recognitionStatus()).isEqualTo("CONFIRMED"); + assertThat(response.version()).isEqualTo(2); + verify(repository, never()).updateWeight(any()); + verify(repository, never()).insertRevision(any()); + verify(repository, never()).updateInbound(any()); + } + + @Test + void confirmingWithStaleVersionDoesNotTreatRequestAsIdempotent() { + InboundOrderEntity inbound = inbound("MEASURING"); + InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2); + weight.setFinalWeightKg(new BigDecimal("285.500")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + + assertThatThrownBy(() -> service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(1, null, null, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("版本已变化"); + + verify(repository, never()).updateWeight(any()); + verify(repository, never()).insertRevision(any()); + } + + @Test + void confirmingAdjustedWeightStopsBeforeRecalculationWhenConfirmUpdateConflicts() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true, false); + + assertThatThrownBy(() -> service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest( + 0, new BigDecimal("286.125"), "KG", "并发冲突测试"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("版本已变化"); + + verify(repository, times(2)).updateWeight(weight); + verify(repository, times(1)).insertRevision(any(InboundWeightRevisionEntity.class)); + verify(repository, never()).listWeights(anyLong(), anyLong()); + verify(repository, never()).updateInbound(any()); + } + @Test void submitAdvancesOnlyToMeasurePendingAfterAllWeightsConfirmed() { InboundOrderEntity inbound = inbound("MEASURING"); @@ -121,6 +375,7 @@ class InboundMeasurementServiceTest { measurement.setTotalVolumeCbm(new BigDecimal("1.2345")); measurement.setVolumeWeightKg(new BigDecimal("206.162")); InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setPackageCount(3); item.setSourceImageFileId(500L); InboundWeightResultEntity weight = weight(20L, "20.000", "KG", "CONFIRMED", 1); weight.setFinalWeightKg(new BigDecimal("20.000")); @@ -150,6 +405,7 @@ class InboundMeasurementServiceTest { measurement.setStatus("DRAFT"); measurement.setVersion(0); InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setPackageCount(3); item.setSourceImageFileId(500L); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement); @@ -227,6 +483,7 @@ class InboundMeasurementServiceTest { inbound.setId(10L); inbound.setTenantId(1L); inbound.setProcessStatus(status); + inbound.setTotalPackageCount(3); inbound.setVersion(0); return inbound; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java index 14e42fd17..7995676b0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java @@ -2,6 +2,8 @@ package com.baiyue.firstleg.service; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; import com.baiyue.firstleg.mapper.InboundOrderMapper; @@ -14,7 +16,10 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -46,14 +51,52 @@ class InboundOrderCreationServiceTest { verify(histories, times(1)).insert(any(InboundStatusHistoryEntity.class)); assertThat(inboundCaptor.getValue().getInboundCode()).isEqualTo("RK-CUSTOM-77"); assertThat(inboundCaptor.getValue().getCarrierId()).isNull(); + assertThat(inboundCaptor.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION"); + assertThat(inboundCaptor.getValue().getPricingTaxMode()).isEqualTo("TAX_EXCLUDED"); assertThat(createdId).isEqualTo(inboundCaptor.getValue().getId()); assertThat(replayedId).isEqualTo(900L); } + @Test + void shouldRejectReplayWithDifferentInboundCode() { + InboundOrderEntity existing = inbound(900L, "RK-OLD", "UNPRICED", null, null); + when(orders.selectOne(any(Wrapper.class))).thenReturn(existing); + + assertThatThrownBy(() -> service.createFromApprovedOrder(approvedCommand("RK-NEW"))) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(orders, never()).insert(any(InboundOrderEntity.class)); + } + + @Test + void shouldConvergeOnConcurrentDuplicateInsertWinner() { + InboundOrderEntity winner = inbound(901L, "RK-CUSTOM-77", "UNPRICED", null, null); + when(orders.selectOne(any(Wrapper.class))).thenReturn(null, winner); + doThrow(new org.springframework.dao.DuplicateKeyException("concurrent insert")) + .when(orders).insert(any(InboundOrderEntity.class)); + + assertThat(service.createFromApprovedOrder(approvedCommand("RK-CUSTOM-77"))).isEqualTo(901L); + verify(histories, never()).insert(any(InboundStatusHistoryEntity.class)); + } + + private static InboundOrderEntity inbound(long id, String code, String feeStatus, + Long carrierId, String carrierName) { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(id); + inbound.setTenantId(10001L); + inbound.setOrderId(77L); + inbound.setInboundCode(code); + inbound.setFeeStatus(feeStatus); + inbound.setCarrierId(carrierId); + inbound.setCarrierName(carrierName); + return inbound; + } + private static OrderApprovedInboundCommand approvedCommand(String inboundCode) { return new OrderApprovedInboundCommand( 10001L, 77L, "FL-77", inboundCode, 7L, "测试客户", 9L, "义乌仓", null, null, null, "FBA", "AIR", "空运", null, null, - null, null, null, null, null, null, null, null, null, null); + "BUY_DECLARATION", "TAX_EXCLUDED", null, null, null, null, null, null, null, null, null); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java index 3907b012f..e927df0dc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java @@ -6,6 +6,7 @@ import com.baiyue.firstleg.config.InboundProperties; import com.baiyue.firstleg.dto.ExceptionReleaseRequest; import com.baiyue.firstleg.dto.MeasurementReviewRequest; import com.baiyue.firstleg.entity.InboundMeasurementEntity; +import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; import com.baiyue.firstleg.repository.InboundWorkflowRepository; @@ -79,6 +80,23 @@ class MeasurementReviewServiceTest { assertThat(history.getValue().getToStatus()).isEqualTo("INBOUNDED"); } + @Test + @DisplayName("确认箱数合计不等于订单总箱数时不能审核通过") + void shouldRejectApprovalWhenConfirmedPackageCountDoesNotMatchOrder() { + Fixture fixture = fixture(false); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setPackageCount(4); + when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item)); + + assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-mismatch", + new MeasurementReviewRequest(20L, 3, null))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.VALIDATION_FAILED); + + verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean()); + } + @Test @DisplayName("驳回原因必填且状态推进为测量驳回") void shouldRequireRejectReason() { @@ -175,10 +193,15 @@ class MeasurementReviewServiceTest { InboundOrderEntity order = new InboundOrderEntity(); order.setProcessStatus("MEASURE_PENDING"); order.setMeasureReviewStatus("PENDING"); + order.setTotalPackageCount(5); InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(20L); measurement.setStatus("SUBMITTED"); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setPackageCount(5); when(repository.findOwned(10001L, 10L)).thenReturn(order); when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement); + when(repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item)); return new Fixture(service, repository, idempotency, order, measurement); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java new file mode 100644 index 000000000..ad4d849dd --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java @@ -0,0 +1,273 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.PriceBookWarehouseClient; +import com.baiyue.firstleg.domain.PriceModel; +import com.baiyue.firstleg.domain.PriceRegion; +import com.baiyue.firstleg.domain.PricingDestinationType; +import com.baiyue.firstleg.domain.PricingTaxMode; +import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest; +import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; +import com.baiyue.firstleg.pricing.FbaWarehouseRateEstimator; +import com.baiyue.firstleg.pricing.PriceBookWarehouseRegionResolver; +import com.baiyue.firstleg.pricing.PriceRegionResolver; +import com.baiyue.firstleg.pricing.RegionWeightTierEstimator; +import com.baiyue.firstleg.repository.PriceBookQueryRepository; +import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.math.BigDecimal; +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig({MerchantPriceBookService.class, ActivePriceBookEstimator.class, + FbaWarehouseRateEstimator.class, RegionWeightTierEstimator.class, PriceRegionResolver.class, + PriceBookWarehouseRegionResolver.class}) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class MerchantPriceBookServiceTest { + @MockBean private PriceBookQueryRepository repository; + @MockBean private PriceBookWarehouseClient warehouses; + @Resource private MerchantPriceBookService service; + + @Test + void shouldReturnEmptyChannelsWithoutActiveVersion() { + when(repository.activeVersionId()).thenReturn(null); + + assertThat(service.channels(10001L, 10L)).isEmpty(); + verify(repository, never()).channels(1L, 10L); + verify(repository, never()).channelTaxModes(1L, 10L); + } + + @Test + void shouldExposeOnlyChannelsFromLatestActiveVersionAfterSwitch() { + when(repository.activeVersionId()).thenReturn(1L, 2L); + when(repository.channels(1L, 10L)).thenReturn(List.of(channel(1L, "OLD", PriceModel.FBA_WAREHOUSE_RATE))); + when(repository.channels(2L, 10L)).thenReturn(List.of(channel(2L, "NEW", PriceModel.FBA_WAREHOUSE_RATE))); + + assertThat(service.channels(10001L, 10L)).extracting(value -> value.channelCode()) + .containsExactly("OLD"); + assertThat(service.channels(10001L, 10L)).extracting(value -> value.channelCode()) + .containsExactly("NEW"); + } + + @Test + void shouldExposeDistinctPricingTaxModesForActiveOriginChannels() { + when(repository.activeVersionId()).thenReturn(7L); + when(repository.channels(7L, 10L)).thenReturn(List.of( + channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE), + channel(7L, "OA_PARCEL", PriceModel.REGION_WEIGHT_TIER))); + when(repository.channelTaxModes(7L, 10L)).thenReturn(List.of( + taxMode("MATSON_CLX_TRUCK", PricingTaxMode.TAX_EXCLUDED), + taxMode("MATSON_CLX_TRUCK", PricingTaxMode.TAX_INCLUDED), + taxMode("MATSON_CLX_TRUCK", PricingTaxMode.TAX_INCLUDED), + taxMode("OA_PARCEL", PricingTaxMode.TAX_INCLUDED))); + + var channels = service.channels(10001L, 10L); + + assertThat(channels.get(0).pricingTaxModes()) + .containsExactly(PricingTaxMode.TAX_INCLUDED, PricingTaxMode.TAX_EXCLUDED); + assertThat(channels.get(1).pricingTaxModes()).containsExactly(PricingTaxMode.TAX_INCLUDED); + } + + @Test + void shouldKeepActiveChannelWhenItsTaxModeProjectionIsEmpty() { + when(repository.activeVersionId()).thenReturn(7L); + when(repository.channels(7L, 10L)).thenReturn(List.of( + channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE))); + when(repository.channelTaxModes(7L, 10L)).thenReturn(List.of()); + + assertThat(service.channels(10001L, 10L)).singleElement() + .extracting(value -> value.pricingTaxModes()) + .isEqualTo(List.of()); + } + + @Test + void shouldRejectForgedOrRemovedChannel() { + when(repository.activeVersionId()).thenReturn(2L); + when(repository.channels(2L, 10L)).thenReturn(List.of(channel(2L, "ACTIVE_CHANNEL", + PriceModel.FBA_WAREHOUSE_RATE))); + + assertThatThrownBy(() -> service.estimate(10001L, + request("FORGED", PricingDestinationType.FBA, "ONT8", null, null, + PricingTaxMode.TAX_INCLUDED, "10", "0.1"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("已下线"); + } + + @Test + void shouldEstimateFbaDestinationWithIndependentPricingTaxMode() { + stubChannel(7L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE); + when(repository.fbaRates(7L, 10L, "MATSON_TRUCK", "ONT8", "TAX_INCLUDED")) + .thenReturn(List.of(rate(PriceModel.FBA_WAREHOUSE_RATE, "KG", null, "12.50"))); + + var result = service.estimate(10001L, + request(" matson_truck ", PricingDestinationType.FBA, "ont8", null, null, + PricingTaxMode.TAX_INCLUDED, "20", "0.2")); + + assertThat(result.priceBookVersionId()).isEqualTo(7L); + assertThat(result.pricingTaxMode()).isEqualTo(PricingTaxMode.TAX_INCLUDED); + assertThat(result.chargeableQuantity()).isEqualByComparingTo("33.400000"); + assertThat(result.estimatedAmount()).isEqualByComparingTo("417.50"); + assertThat(result.disclaimer()).isEqualTo(MerchantPriceBookService.DISCLAIMER); + } + + @Test + void shouldEstimateBoundOverseasWarehouse() { + stubChannel(7L, "EXX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE); + when(repository.overseasRates(7L, 10L, "EXX_TRUCK", 901L, "TAX_EXCLUDED")) + .thenReturn(List.of(rate(PriceModel.FBA_WAREHOUSE_RATE, "CBM", null, "880"))); + + var result = service.estimate(10001L, + request("EXX_TRUCK", PricingDestinationType.OVERSEAS, null, 901L, null, + PricingTaxMode.TAX_EXCLUDED, "363", "0.5")); + + assertThat(result.chargeUnit()).isEqualTo("CBM"); + assertThat(result.chargeableQuantity()).isEqualByComparingTo("1.000000"); + assertThat(result.estimatedAmount()).isEqualByComparingTo("880.00"); + } + + @Test + void shouldUseExplicitRegionForTierEstimate() { + stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER); + when(repository.regionTiers(8L, 10L, "MATSON_PARCEL", "WEST", "TAX_INCLUDED")) + .thenReturn(tiers()); + + var result = service.estimate(10001L, + request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, PriceRegion.WEST, + PricingTaxMode.TAX_INCLUDED, "51", null)); + + assertThat(result.priceModel()).isEqualTo(PriceModel.REGION_WEIGHT_TIER); + assertThat(result.unitPrice()).isEqualByComparingTo("13"); + assertThat(result.estimatedAmount()).isEqualByComparingTo("663.00"); + } + + @Test + void shouldUseIndependentPricingTaxModeForTierEstimateWhenConfigured() { + stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER); + when(repository.regionTiers(8L, 10L, "MATSON_PARCEL", "WEST", "TAX_INCLUDED")) + .thenReturn(tiers()); + + var result = service.estimate(10001L, + request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, PriceRegion.WEST, + PricingTaxMode.TAX_INCLUDED, "51", null)); + + assertThat(result.pricingTaxMode()).isEqualTo(PricingTaxMode.TAX_INCLUDED); + verify(repository).regionTiers(8L, 10L, "MATSON_PARCEL", "WEST", "TAX_INCLUDED"); + } + + @Test + void shouldResolveTierRegionFromPlatformFbaWarehousePostalCode() { + stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER); + when(repository.regionTiers(8L, 10L, "MATSON_PARCEL", "EAST", "TAX_INCLUDED")) + .thenReturn(tiers()); + var warehouse = new PriceBookWarehouseClient.Warehouse(501L, 0L, "FBA-NJ", "NJ FBA", + "FBA", "US", "ACTIVE"); + var address = new PriceBookWarehouseClient.Address("US", "NJ", "Newark", null, + "07114", "address", null); + when(warehouses.active("internal-token", 0L, 501L)) + .thenReturn(ApiResponse.success(new PriceBookWarehouseClient.Detail(warehouse, List.of(address)))); + + var result = service.estimate(10001L, + request("MATSON_PARCEL", PricingDestinationType.FBA, null, 501L, null, + PricingTaxMode.TAX_INCLUDED, "12", null)); + + assertThat(result.unitPrice()).isEqualByComparingTo("15"); + verify(repository).regionTiers(8L, 10L, "MATSON_PARCEL", "EAST", "TAX_INCLUDED"); + } + + @Test + void shouldRejectMissingRequiredEstimateFields() { + stubChannel(7L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE); + + assertThatThrownBy(() -> service.estimate(10001L, + request("MATSON_TRUCK", PricingDestinationType.FBA, "ONT8", null, null, + null, "10", null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("包税或不包税"); + } + + @Test + void shouldNotGuessRegionWithoutExplicitRegionOrFbaWarehouse() { + stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER); + + assertThatThrownBy(() -> service.estimate(10001L, + request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, null, + PricingTaxMode.TAX_INCLUDED, "51", null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("目的仓区域或平台FBA仓"); + } + + @Test + void shouldRejectTierEstimateWithoutPricingTaxMode() { + stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER); + + assertThatThrownBy(() -> service.estimate(10001L, + request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, PriceRegion.WEST, + null, "51", null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("包税或不包税"); + } + + private void stubChannel(long versionId, String code, PriceModel model) { + when(repository.activeVersionId()).thenReturn(versionId); + when(repository.channels(versionId, 10L)).thenReturn(List.of(channel(versionId, code, model))); + } + + private static EstimateRequest request(String channelCode, PricingDestinationType destinationType, + String destinationCode, Long destinationWarehouseId, + PriceRegion destinationRegion, PricingTaxMode pricingTaxMode, + String weight, String volume) { + return new EstimateRequest(10L, channelCode, destinationCode, destinationType, + destinationWarehouseId, null, destinationRegion, pricingTaxMode, + decimal(weight), decimal(volume)); + } + + private static ChannelRow channel(long versionId, String code, PriceModel model) { + ChannelRow row = new ChannelRow(); + row.setPriceBookVersionId(versionId); + row.setChannelCode(code); + row.setChannelName(code); + row.setPriceModel(model.name()); + return row; + } + + private static ChannelTaxModeRow taxMode(String channelCode, PricingTaxMode pricingTaxMode) { + ChannelTaxModeRow row = new ChannelTaxModeRow(); + row.setChannelCode(channelCode); + row.setPricingTaxMode(pricingTaxMode.name()); + return row; + } + + private static List tiers() { + return List.of( + rate(PriceModel.REGION_WEIGHT_TIER, "KG", "12", "15"), + rate(PriceModel.REGION_WEIGHT_TIER, "KG", "51", "13"), + rate(PriceModel.REGION_WEIGHT_TIER, "KG", "101", "11")); + } + + private static RateRow rate(PriceModel model, String unit, String from, String price) { + RateRow row = new RateRow(); + row.setPriceModel(model.name()); + row.setChargeUnit(unit); + row.setWeightFromKg(decimal(from)); + row.setUnitPrice(new BigDecimal(price)); + row.setCurrency("CNY"); + return row; + } + + private static BigDecimal decimal(String value) { + return value == null ? null : new BigDecimal(value); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java new file mode 100644 index 000000000..42652dc2d --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java @@ -0,0 +1,346 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.PriceBookFileClient; +import com.baiyue.firstleg.client.PriceBookWarehouseClient; +import com.baiyue.firstleg.domain.PriceBookStatus; +import com.baiyue.firstleg.domain.PriceModel; +import com.baiyue.firstleg.domain.PricingDestinationType; +import com.baiyue.firstleg.domain.PricingTaxMode; +import com.baiyue.firstleg.dto.PriceBookConfirmationRequest; +import com.baiyue.firstleg.dto.PriceBookDtos; +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; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +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.ArgumentMatchers.any; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PlatformPriceBookServiceTest { + @Mock private PriceBookRepository repository; + @Mock private PriceBookFileClient files; + @Mock private PriceBookWarehouseClient warehouses; + @Mock private TransactionTemplate transactions; + @Mock private TransactionStatus transactionStatus; + private PlatformPriceBookService service; + + @BeforeEach + void setUp() { + service = new PlatformPriceBookService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "files", files); + ReflectionTestUtils.setField(service, "warehouses", warehouses); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + ReflectionTestUtils.setField(service, "transactions", transactions); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + } + + @Test + void fileServiceFailureMarksAllocatedVersionFailed() { + executeTransactionsImmediately(); + org.mockito.Mockito.doAnswer(invocation -> { + PriceBookVersionEntity value = invocation.getArgument(0); + value.setId(9007199254740993L); + return 1; + }).when(repository).insertVersion(any()); + when(repository.nextVersionNo()).thenReturn(1); + when(files.upload(any(), any(Long.class), any(Long.class), any())).thenReturn(null); + var upload = new MockMultipartFile("file", "price.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{1}); + + assertThatThrownBy(() -> service.upload(7L, upload)) + .isInstanceOf(BizException.class) + .hasMessageContaining("原文件保存失败"); + + ArgumentCaptor saved = ArgumentCaptor.forClass(PriceBookVersionEntity.class); + verify(repository).updateVersion(saved.capture()); + assertThat(saved.getValue().getId()).isEqualTo(9007199254740993L); + assertThat(saved.getValue().getStatus()).isEqualTo(PriceBookStatus.FAILED.name()); + assertThat(saved.getValue().getBlockingErrorCount()).isEqualTo(1); + } + + @Test + void parseFailureKeepsImmutableSourceFileMetadataOnFailedVersion() { + executeTransactionsImmediately(); + org.mockito.Mockito.doAnswer(invocation -> { + PriceBookVersionEntity value = invocation.getArgument(0); + value.setId(88L); + return 1; + }).when(repository).insertVersion(any()); + when(repository.nextVersionNo()).thenReturn(2); + when(files.upload(any(), any(Long.class), any(Long.class), any())).thenReturn(ApiResponse.success( + new PriceBookFileClient.FileMetadata(501L, 88L, "price.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 3L, "abc123", "FBA_PRICE_BOOK_SOURCE"))); + when(files.download(any(), any(Long.class), any(Long.class), any(Long.class))) + .thenReturn(ResponseEntity.ok(new byte[]{1, 2, 3})); + var invalid = new MockMultipartFile("file", "price.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{1, 2, 3}); + + assertThatThrownBy(() -> service.upload(7L, invalid)) + .isInstanceOf(BizException.class); + + ArgumentCaptor saved = ArgumentCaptor.forClass(PriceBookVersionEntity.class); + verify(repository).updateVersion(saved.capture()); + assertThat(saved.getValue().getStatus()).isEqualTo(PriceBookStatus.FAILED.name()); + assertThat(saved.getValue().getSourceFileId()).isEqualTo(501L); + assertThat(saved.getValue().getSourceSha256()).isEqualTo("abc123"); + assertThat(saved.getValue().getParseIssuesJson()).contains("UPLOAD_OR_PARSE_FAILED"); + } + + @Test + void exposesOnlyUploadConfirmationAndReadOperations() { + assertThat(Arrays.stream(PlatformPriceBookService.class.getDeclaredMethods()) + .filter(method -> java.lang.reflect.Modifier.isPublic(method.getModifiers())) + .map(java.lang.reflect.Method::getName).toList()) + .containsExactlyInAnyOrder("list", "upload", "detail", "publish", "source"); + } + + @Test + void pendingDetailWarnsThatMissingActiveChannelWillBeRemoved() { + PriceBookVersionEntity pendingVersion = version(13L, PriceBookStatus.PENDING_CONFIRMATION); + PriceBookVersionEntity active = version(12L, PriceBookStatus.ACTIVE); + when(repository.findVersion(13L)).thenReturn(pendingVersion); + when(repository.findActive()).thenReturn(active); + when(repository.listChannels(13L)).thenReturn(List.of()); + when(repository.listChannels(12L)).thenReturn(List.of(channel("MATSON_CLX_TRUCK"))); + when(repository.listRates(13L)).thenReturn(List.of()); + + var detail = service.detail(13L); + + assertThat(detail.version().status()).isEqualTo(PriceBookStatus.PENDING_CONFIRMATION); + assertThat(detail.issues()).extracting(PriceBookDtos.ParseIssue::issueCode) + .containsExactly("CHANNEL_MISSING_FROM_PENDING_UPLOAD"); + } + + @Test + void sourceDownloadIsProxiedWithSafeAttachmentHeaders() { + PriceBookVersionEntity version = version(14L, PriceBookStatus.SUPERSEDED); + version.setSourceFileId(601L); + version.setOriginalFileName("历史价格.xlsx"); + when(repository.findVersion(14L)).thenReturn(version); + when(files.download("internal-token", 7L, 14L, 601L)) + .thenReturn(ResponseEntity.ok().header(HttpHeaders.CONTENT_TYPE, "application/octet-stream") + .body(new byte[]{9, 8, 7})); + + ResponseEntity response = service.source(14L, 7L); + + assertThat(response.getBody()).containsExactly(9, 8, 7); + assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION)) + .startsWith("attachment;").contains("filename*="); + assertThat(response.getHeaders().getFirst("X-Content-Type-Options")).isEqualTo("nosniff"); + } + + @Test + void confirmationMapsWarehousesBeforeReplacingActiveVersion() { + PriceBookVersionEntity pendingVersion = version(21L, PriceBookStatus.PENDING_CONFIRMATION); + PriceBookVersionEntity active = version(20L, PriceBookStatus.ACTIVE); + when(repository.findVersion(21L)).thenReturn(pendingVersion); + when(repository.listChannels(21L)).thenReturn(List.of(channel("MATSON_CLX_TRUCK"))); + when(repository.listRates(21L)).thenReturn(List.of(rate(77L))); + when(repository.activeCatalog()).thenReturn(List.of(catalog("MATSON_CLX_TRUCK"))); + when(repository.listActiveForUpdate()).thenReturn(List.of(active)); + when(warehouses.active("internal-token", 0L, 501L)).thenReturn(ApiResponse.success( + new PriceBookWarehouseClient.Detail(new PriceBookWarehouseClient.Warehouse(501L, 0L, + "CN-YIWU-01", "义乌仓", "DOMESTIC", "CN", "ACTIVE"), List.of()))); + org.mockito.Mockito.doAnswer(invocation -> { + PriceBookVersionEntity value = invocation.getArgument(0); + value.setStatus(PriceBookStatus.ACTIVE.name()); + return 1; + }).when(repository).activate(any(), any(), any(Long.class)); + + service.publish(21L, 7L, confirmation("CN-YIWU-01", 501L)); + + InOrder order = inOrder(repository); + order.verify(repository).lockPlatformVersions(); + order.verify(repository).findVersion(21L); + verify(repository).deletePendingChildren(21L); + verify(repository).supersede(any(PriceBookVersionEntity.class), any(), any(Long.class)); + verify(repository).activate(any(PriceBookVersionEntity.class), any(), any(Long.class)); + ArgumentCaptor pendingRate = ArgumentCaptor.forClass(CarrierFbaRateEntity.class); + verify(repository).insertRate(pendingRate.capture()); + assertThat(pendingRate.getValue().getStatus()).isEqualTo(PriceBookStatus.PENDING_CONFIRMATION.name()); + } + + @Test + void missingWarehouseMappingCannotPublish() { + PriceBookVersionEntity pendingVersion = version(22L, PriceBookStatus.PENDING_CONFIRMATION); + when(repository.findVersion(22L)).thenReturn(pendingVersion); + when(repository.listChannels(22L)).thenReturn(List.of(channel("MATSON_CLX_TRUCK"))); + when(repository.listRates(22L)).thenReturn(List.of(rate(78L))); + when(repository.activeCatalog()).thenReturn(List.of(catalog("MATSON_CLX_TRUCK"))); + + assertThatThrownBy(() -> service.publish(22L, 7L, + new PriceBookConfirmationRequest(List.of(), List.of()))) + .isInstanceOf(BizException.class) + .hasMessageContaining("阻断错误"); + verify(repository, never()).activate(any(), any(), any(Long.class)); + verify(repository, never()).deletePendingChildren(22L); + } + + @Test + void previewDoesNotTreatDifferentUnmappedOriginsAsDuplicateRates() { + CarrierFbaRateEntity yiwu = rate(79L); + CarrierFbaRateEntity dongguan = rate(80L); + dongguan.setOriginWarehouseCode("CN-DONGGUAN-01"); + dongguan.setOriginWarehouseName("东莞仓"); + when(repository.activeCatalog()).thenReturn(List.of(catalog("MATSON_CLX_TRUCK"))); + PriceBookDtos.ParsedChannel parsedChannel = ReflectionTestUtils.invokeMethod( + service, "channelDto", channel("MATSON_CLX_TRUCK")); + PriceBookDtos.ParsedRate parsedYiwu = ReflectionTestUtils.invokeMethod(service, "rateDto", yiwu); + PriceBookDtos.ParsedRate parsedDongguan = ReflectionTestUtils.invokeMethod(service, "rateDto", dongguan); + + @SuppressWarnings("unchecked") + List issues = ReflectionTestUtils.invokeMethod( + service, + "validate", + List.of(parsedChannel), + List.of(parsedYiwu, parsedDongguan) + ); + + assertThat(issues).extracting(PriceBookDtos.ParseIssue::issueCode) + .contains("ORIGIN_MAPPING_REQUIRED") + .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)); + + assertThatThrownBy(() -> service.publish(31L, 7L, confirmation("CN-YIWU-01", 501L))) + .isInstanceOf(BizException.class) + .hasMessageContaining("待确认上传记录"); + + InOrder order = inOrder(repository); + order.verify(repository).lockPlatformVersions(); + order.verify(repository).findVersion(31L); + verify(repository, never()).activate(any(), any(), any(Long.class)); + } + + private static PriceBookVersionEntity version(long id, PriceBookStatus status) { + PriceBookVersionEntity value = new PriceBookVersionEntity(); + value.setId(id); + value.setTenantId(0L); + value.setVersionNo((int) id); + value.setOriginalFileName("price.xlsx"); + value.setStatus(status.name()); + value.setChannelCount(0); + value.setRateCount(0); + value.setBlockingErrorCount(0); + value.setWarningCount(0); + value.setDeleted(false); + return value; + } + + private static PriceBookChannelEntity channel(String code) { + PriceBookChannelEntity value = new PriceBookChannelEntity(); + value.setChannelCode(code); + value.setChannelName(code); + value.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name()); + value.setSourceSheetName(code); + return value; + } + + private static PriceChannelCatalogEntity catalog(String code) { + PriceChannelCatalogEntity value = new PriceChannelCatalogEntity(); + value.setChannelCode(code); + value.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name()); + value.setStatus("ACTIVE"); + return value; + } + + private static CarrierFbaRateEntity rate(long id) { + CarrierFbaRateEntity value = new CarrierFbaRateEntity(); + value.setId(id); + value.setChannelCode("MATSON_CLX_TRUCK"); + value.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name()); + value.setOriginWarehouseCode("CN-YIWU-01"); + value.setOriginWarehouseName("义乌仓"); + value.setDestinationType(PricingDestinationType.FBA.name()); + value.setDestinationCode("ONT8"); + value.setPricingTaxMode(PricingTaxMode.TAX_INCLUDED.name()); + value.setChargeUnit("KG"); + value.setUnitPrice(new BigDecimal("10")); + value.setCurrency("CNY"); + value.setSourceSheetName("美森正班海卡"); + value.setSourceRowNumber(5); + value.setSourceDestinationText("ONT8"); + return value; + } + + private static PriceBookConfirmationRequest confirmation(String code, long id) { + return new PriceBookConfirmationRequest( + 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") + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(transactionStatus); + }); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java b/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java index 2f5e6808d..a204d907e 100644 --- a/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java +++ b/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java @@ -12,6 +12,8 @@ import java.time.LocalDateTime; public interface AuthAccountClient { @PostMapping ApiResponse create(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody CreateAccountRequest request); + @GetMapping("/availability") ApiResponse availability(@RequestHeader("X-Internal-Service-Token") String token, + @RequestParam String username); @GetMapping("/{id}") ApiResponse summary(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable Long id); @PutMapping("/{id}/status") ApiResponse status(@RequestHeader("X-Internal-Service-Token") String token, diff --git a/iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java b/iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java new file mode 100644 index 000000000..ec6a148f9 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java @@ -0,0 +1,29 @@ +package com.baiyue.iam.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +/** Firstleg internal contract for provisioning one customer dimension rule. */ +@FeignClient(name = "firstleg-service", contextId = "customerDimensionRuleClient", + url = "${baiyue.clients.firstleg.url:}", + path = "/internal/firstleg/v1/customer-dimension-configs") +public interface CustomerDimensionRuleClient { + @PostMapping("/defaults") + ApiResponse initialize( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody InitializationRequest request); + + @DeleteMapping("/defaults") + ApiResponse compensate( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestParam long tenantId, + @RequestParam String initializationKey); + + record InitializationRequest(long tenantId, long operatorId, String initializationKey) { } + record InitializationResponse(Long configId, boolean createdByThisInitialization) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index 373035190..b3c1c996d 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -73,6 +73,17 @@ public class PlatformTenantController { return ApiResponse.success(service.merchant(tenantId)); } + @GetMapping("/tenants/contact-phone-availability") + @PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')") + @Operation(summary = "校验客户联系电话唯一性") + public ApiResponse contactPhoneAvailability( + @RequestParam String contactPhone, + @RequestParam(required = false) Long excludeTenantId, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.contactPhoneAndUsernameAvailable(contactPhone, excludeTenantId)); + } + @GetMapping("/tenants/{tenantId:\\d+}/members") @PreAuthorize("hasAuthority('platform:customer:view')") @Operation(summary = "查询企业客户成员及账号", description = "平台只读查询指定企业的全部成员,包含 OWNER 企业管理员,并返回登录账号。") diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index 04cdce90b..cac507ae6 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -27,7 +27,8 @@ public final class TenantDtos { @Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName, @Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative, @Schema(description = "Primary contact / 联系人", example = "张三") @NotBlank String contactName, - @Schema(description = "Contact phone / 联系电话", example = "13800138000") @NotBlank String contactPhone, + @Schema(description = "Contact phone and administrator username / 联系电话及管理员用户名", example = "13800138000") + @NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone, @Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") @Email String contactEmail, @NotBlank @Size(max = 64) String servicePackage, @Size(max = 64) String contactWechat, @@ -58,7 +59,7 @@ public final class TenantDtos { @NotBlank String legalName, @NotBlank @Size(max = 64) String legalRepresentative, @NotBlank String contactName, - @NotBlank String contactPhone, + @NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone, @Email String contactEmail, @NotBlank @Size(max = 64) String servicePackage, @Size(max = 64) String contactWechat, @@ -86,7 +87,7 @@ public final class TenantDtos { public record ApprovalResponse( @Schema(description = "Tenant ID / 企业租户 ID", example = "190000000000000001") Long tenantId, @Schema(description = "Whether this approval created the administrator / 本次审核是否新建管理员") boolean administratorCreated, - @Schema(description = "Generated login username; null when an owner already exists / 自动生成的登录账号,已有所有者时为空", nullable = true) String administratorUsername, + @Schema(description = "Contact phone used as login username; null when an owner already exists / 使用联系电话作为登录账号,已有所有者时为空", nullable = true) String administratorUsername, @Schema(description = "One-time temporary password; only returned when created / 一次性初始密码,仅新建时返回", format = "password", nullable = true) String temporaryPassword ) { } diff --git a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java index aea4b7c84..f023f730b 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java @@ -34,6 +34,19 @@ public class TenantRepository { .ne(IamTenant::getId, tenantId)) > 0; } + public boolean existsByContactPhone(String contactPhone) { + return tenantMapper.selectCount(new LambdaQueryWrapper() + .eq(IamTenant::getTenantType, "MERCHANT") + .eq(IamTenant::getContactPhone, contactPhone)) > 0; + } + + public boolean existsByContactPhoneForOtherTenant(String contactPhone, long tenantId) { + return tenantMapper.selectCount(new LambdaQueryWrapper() + .eq(IamTenant::getTenantType, "MERCHANT") + .eq(IamTenant::getContactPhone, contactPhone) + .ne(IamTenant::getId, tenantId)) > 0; + } + public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); } /** * Serializes the draft qualification workflow for one enterprise. The file service and the diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java new file mode 100644 index 000000000..050ae2305 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java @@ -0,0 +1,119 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.client.CustomerDimensionRuleClient; +import jakarta.annotation.Resource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** Provisions the customer dimension rule and compensates it when local customer creation rolls back. */ +@Service +public class CustomerDimensionRuleInitializationService { + private static final Logger log = LoggerFactory.getLogger(CustomerDimensionRuleInitializationService.class); + private static final int MAX_ATTEMPTS = 3; + private static final String KEY_PREFIX = "iam-customer-create:"; + + @Resource + private CustomerDimensionRuleClient client; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; + + public InitializationResult initialize(long tenantId, long operatorId) { + String initializationKey = initializationKey(tenantId); + RuntimeException lastFailure = null; + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + ApiResponse response = client.initialize( + internalToken, + new CustomerDimensionRuleClient.InitializationRequest( + tenantId, operatorId, initializationKey)); + if (response != null && response.successful() && response.data() != null) { + InitializationResult result = new InitializationResult( + response.data().createdByThisInitialization(), initializationKey); + registerRollbackCompensation(tenantId, result); + return result; + } + lastFailure = unavailable(response); + log.warn("客户尺寸规则初始化失败,tenantId={}, attempt={}, code={}, requestId={}, traceId={}", + tenantId, attempt, response == null ? null : response.code(), + response == null ? null : response.requestId(), + response == null ? null : response.traceId()); + } catch (RuntimeException failure) { + lastFailure = failure; + log.warn("客户尺寸规则初始化调用异常,tenantId={}, attempt={}", tenantId, attempt, failure); + } + } + compensateAfterUncertainInitialization(tenantId, initializationKey); + throw unavailable(lastFailure); + } + + /** Used when Auth compensates a committed client registration. */ + public void compensateRegistration(long tenantId) { + compensateRequired(tenantId, initializationKey(tenantId)); + } + + private void registerRollbackCompensation(long tenantId, InitializationResult result) { + if (!result.created() || !TransactionSynchronizationManager.isSynchronizationActive()) return; + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + if (status != STATUS_ROLLED_BACK) return; + try { + compensateRequired(tenantId, result.initializationKey()); + } catch (RuntimeException compensationFailure) { + log.error("客户创建事务回滚后尺寸规则补偿失败,tenantId={}, initializationKey={}", + tenantId, result.initializationKey(), compensationFailure); + } + } + }); + } + + private void compensateAfterUncertainInitialization(long tenantId, String initializationKey) { + try { + compensateRequired(tenantId, initializationKey); + } catch (RuntimeException compensationFailure) { + log.error("客户尺寸规则初始化结果不确定且补偿失败,tenantId={}, initializationKey={}", + tenantId, initializationKey, compensationFailure); + } + } + + private void compensateRequired(long tenantId, String initializationKey) { + RuntimeException lastFailure = null; + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + ApiResponse response = client.compensate(internalToken, tenantId, initializationKey); + if (response != null && response.successful()) return; + lastFailure = unavailable(response); + } catch (RuntimeException failure) { + lastFailure = failure; + } + } + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, + "客户尺寸规则补偿失败,请根据客户ID修复数据一致性: " + tenantId); + } + + private static BizException unavailable(ApiResponse response) { + String detail = response == null || response.message() == null || response.message().isBlank() + ? "Firstleg 服务未返回有效响应" : response.message(); + return new BizException(ResultCode.SERVICE_UNAVAILABLE, "默认尺寸规则初始化失败: " + detail); + } + + private static BizException unavailable(RuntimeException cause) { + String detail = cause == null || cause.getMessage() == null || cause.getMessage().isBlank() + ? "Firstleg 服务暂时不可用" : cause.getMessage(); + return new BizException(ResultCode.SERVICE_UNAVAILABLE, + "默认尺寸规则初始化失败,已重试 3 次: " + detail); + } + + private static String initializationKey(long tenantId) { + return KEY_PREFIX + tenantId; + } + + public record InitializationResult(boolean created, String initializationKey) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java index 59e78e689..84fb530ee 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java @@ -40,6 +40,7 @@ public class MerchantOnboardingService { @Resource private IamTenantAddressMapper addressMapper; @Resource private IamTenantQualificationMapper qualificationMapper; @Resource private IamTenantReviewMapper reviewMapper; + @Resource private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @Transactional public MerchantRegistrationResponse register(MerchantRegistrationRequest request) { @@ -118,6 +119,7 @@ public class MerchantOnboardingService { address.setIsDefault(true); addressMapper.insert(address); } + dimensionRuleInitialization.initialize(tenantId, request.authUserId()); return new MerchantRegistrationResponse(tenantId, membershipId, tenant.getStatus()); } @@ -159,6 +161,8 @@ public class MerchantOnboardingService { "IAM registration compensation is unsafe: onboarding has progressed"); } + dimensionRuleInitialization.compensateRegistration(tenant.getId()); + membershipRoleMapper.delete(new LambdaQueryWrapper() .eq(IamMembershipRole::getMembershipId, membership.getId())); membershipMapper.deleteById(membership.getId()); diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java index 56eb0053d..f0a8d1b8e 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java @@ -34,8 +34,6 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni private static final char[] DIGITS = "23456789".toCharArray(); private static final char[] ALL = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789".toCharArray(); private static final int PASSWORD_LENGTH = 16; - private static final int USERNAME_SUFFIX_LENGTH = 8; - private static final int USERNAME_ATTEMPTS = 3; @Resource private MemberRepository members; @@ -179,18 +177,9 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni private String createAuthAccountWithUniqueUsername(long authUserId, String password, IamTenant tenant, long membershipId) { - for (int attempt = 0; attempt < USERNAME_ATTEMPTS; attempt++) { - String username = username(tenant.getId()); - try { - createAuthAccount(authUserId, username, password, tenant, membershipId); - return username; - } catch (BizException exception) { - if (exception.getResultCode() != ResultCode.CONFLICT || attempt == USERNAME_ATTEMPTS - 1) { - throw exception; - } - } - } - throw new IllegalStateException("Username generation attempts exhausted"); + String username = username(tenant.getContactPhone()); + createAuthAccount(authUserId, username, password, tenant, membershipId); + return username; } private void createAuthAccount(long authUserId, String username, String password, @@ -208,7 +197,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni if (response.data() == null) throw unavailable(); } catch (FeignException exception) { if (exception.status() == 409) { - throw new BizException(ResultCode.CONFLICT, "自动生成的企业管理员登录账号已存在"); + throw new BizException(ResultCode.CONFLICT, "联系电话已被其他账号使用,不能作为企业管理员登录用户名"); } throw unavailable(); } catch (BizException exception) { @@ -244,10 +233,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni return new String(value); } - private String username(long tenantId) { - char[] suffix = new char[USERNAME_SUFFIX_LENGTH]; - for (int index = 0; index < suffix.length; index++) suffix[index] = pick(LOWER); - return "merchant_" + tenantId + "_" + new String(suffix); + private String username(String contactPhone) { + return contactPhone == null ? "" : contactPhone.trim(); } private char pick(char[] source) { return source[random.nextInt(source.length)]; } diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index 6c5e9aaf8..22329a090 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -3,6 +3,7 @@ import jakarta.annotation.Resource; import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.iam.entity.IamTenant; @@ -10,6 +11,7 @@ import com.baiyue.iam.entity.IamTenantQualification; import com.baiyue.iam.entity.IamTenantReview; import com.baiyue.iam.repository.TenantRepository; import com.baiyue.iam.repository.ProfileResourceRepository; +import com.baiyue.iam.client.AuthAccountClient; import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse; import com.baiyue.iam.dto.TenantDtos.RegisterRequest; @@ -23,7 +25,9 @@ import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportRow; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Value; import org.springframework.transaction.annotation.Transactional; +import feign.FeignException; import java.time.LocalDateTime; import java.util.List; @@ -41,7 +45,13 @@ public class TenantService { @Resource private ProfileResourceRepository profileRepository; @Resource + private AuthAccountClient authClient; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; + @Resource private TenantApprovalInitializationCoordinator approvalInitialization; + @Resource + private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @BusinessAudit( @@ -53,6 +63,8 @@ public class TenantService { ) @Transactional public TenantResponse register(RegisterRequest request, long operatorId) { + String contactPhone = normalizeContactPhone(request.contactPhone()); + ensureContactPhoneAvailable(contactPhone, null); IamTenant tenant = new IamTenant(); tenant.setId(IdWorker.getId()); tenant.setTenantCode("TEN" + tenant.getId()); @@ -63,7 +75,7 @@ public class TenantService { tenant.setLegalRepresentative(request.legalRepresentative()); tenant.setUnifiedSocialCreditCode(null); tenant.setContactName(request.contactName()); - tenant.setContactPhone(request.contactPhone()); + tenant.setContactPhone(contactPhone); tenant.setContactEmail(request.contactEmail()); tenant.setServicePackage(request.servicePackage()); tenant.setContactWechat(request.contactWechat()); @@ -78,6 +90,7 @@ public class TenantService { tenant.setCreatedBy(operatorId); tenant.setUpdatedBy(operatorId); repository.insertTenant(tenant); + dimensionRuleInitialization.initialize(tenant.getId(), operatorId); return response(tenant); } @@ -208,16 +221,46 @@ public class TenantService { return response(requireMerchant(tenantId)); } + public boolean contactPhoneAvailable(String contactPhone, Long tenantId) { + String normalized = normalizeContactPhone(contactPhone); + return tenantId == null + ? !repository.existsByContactPhone(normalized) + : !repository.existsByContactPhoneForOtherTenant(normalized, tenantId); + } + + public boolean contactPhoneAndUsernameAvailable(String contactPhone, Long tenantId) { + String normalized = normalizeContactPhone(contactPhone); + if (!contactPhoneAvailable(normalized, tenantId)) return false; + ApiResponse response; + try { + response = authClient.availability(internalToken, normalized); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能校验联系电话"); + } + if (response == null || !response.successful() || response.data() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能校验联系电话"); + } + return response.data(); + } + @Transactional @BusinessAudit(event = "PLATFORM_TENANT_UPDATE", resourceType = "TENANT", resourceId = "#tenantId", description = "平台编辑企业客户档案") public TenantResponse updateMerchant(long tenantId, UpdateTenantRequest request) { IamTenant tenant = requireMerchant(tenantId); + String contactPhone = normalizeContactPhone(request.contactPhone()); + String existingContactPhone = normalizeContactPhone(tenant.getContactPhone()); + if (!contactPhone.equals(existingContactPhone)) { + if ("ACTIVE".equals(tenant.getStatus()) || "DISABLED".equals(tenant.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "客户已开户,联系电话作为登录用户名不能直接修改"); + } + ensureContactPhoneAvailable(contactPhone, tenantId); + } tenant.setTenantName(request.tenantName()); tenant.setLegalName(request.legalName()); tenant.setLegalRepresentative(request.legalRepresentative()); tenant.setContactName(request.contactName()); - tenant.setContactPhone(request.contactPhone()); + tenant.setContactPhone(contactPhone); tenant.setContactEmail(request.contactEmail()); tenant.setServicePackage(request.servicePackage()); tenant.setContactWechat(request.contactWechat()); @@ -258,6 +301,7 @@ public class TenantService { if (repository.findPendingReviewByTenant(tenantId) != null) { throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review / 客户资料已在审核中"); } + ensureContactPhoneAvailable(normalizeContactPhone(tenant.getContactPhone()), tenantId); if (tenant.getUnifiedSocialCreditCode() == null || !tenant.getUnifiedSocialCreditCode().matches("[0-9A-Z]{18}")) { throw new BizException(ResultCode.BAD_REQUEST, @@ -297,6 +341,16 @@ public class TenantService { return tenant.getId() != null && tenant.getId().longValue() == id; } + private String normalizeContactPhone(String contactPhone) { + return contactPhone == null ? "" : contactPhone.trim(); + } + + private void ensureContactPhoneAvailable(String contactPhone, Long tenantId) { + if (!contactPhoneAvailable(contactPhone, tenantId)) { + throw new BizException(ResultCode.CONFLICT, "联系电话已被其他客户使用,不能作为登录用户名"); + } + } + private boolean hasAllPendingQualifications(List qualifications) { return hasAllQualificationsWithStatus(qualifications, "PENDING"); } diff --git a/iam/src/main/resources/db/customer-dimension-config-permissions.sql b/iam/src/main/resources/db/customer-dimension-config-permissions.sql new file mode 100644 index 000000000..af6bfbef1 --- /dev/null +++ b/iam/src/main/resources/db/customer-dimension-config-permissions.sql @@ -0,0 +1,52 @@ +-- 客户尺寸计算配置权限;仅用于平台后台查看和维护,不控制内部初始化接口。 +USE baiyue_iam; +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS migrate_customer_dimension_config_permissions; +DELIMITER $$ +CREATE PROCEDURE migrate_customer_dimension_config_permissions() +BEGIN + IF EXISTS ( + SELECT 1 + FROM iam_permission + WHERE (id = 5102111 AND permission_code <> 'firstleg:dimension-config:view') + OR (id = 5102112 AND permission_code <> 'firstleg:dimension-config:edit') + OR (permission_code = 'firstleg:dimension-config:view' AND id <> 5102111) + OR (permission_code = 'firstleg:dimension-config:edit' AND id <> 5102112) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'customer dimension config permission id/code conflict'; + END IF; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted) +VALUES + (5102111, 0, 'PLATFORM', 'BUTTON', 5100010, + 'firstleg:dimension-config:view', '查看客户尺寸计算配置', NULL, NULL, 90, 'ACTIVE', 1, 0), + (5102112, 0, 'PLATFORM', 'BUTTON', 5100010, + 'firstleg:dimension-config:edit', '维护客户尺寸计算配置', NULL, NULL, 100, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), + status = 'ACTIVE', built_in = 1, deleted = 0; + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT role.id, permission.id, role.tenant_id +FROM iam_role role +JOIN iam_permission permission + ON permission.tenant_id = 0 + AND permission.app_code = 'PLATFORM' +WHERE role.tenant_id = 0 + AND role.app_code = 'PLATFORM' + AND ( + (role.role_code = 'PLATFORM_ADMIN' + AND permission.permission_code IN ( + 'firstleg:dimension-config:view', 'firstleg:dimension-config:edit')) + OR (role.role_code = 'ORDER_OPERATOR' + AND permission.permission_code = 'firstleg:dimension-config:view') + ); +END$$ +DELIMITER ; + +CALL migrate_customer_dimension_config_permissions(); +DROP PROCEDURE IF EXISTS migrate_customer_dimension_config_permissions; diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index cea557b7a..9d93691e1 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -17,6 +17,25 @@ tags: - name: RBAC description: Platform and merchant role-permission management / 平台端与商户端角色权限管理 paths: + /api/platform/v1/iam/tenants/contact-phone-availability: + get: + tags: [Platform Tenant / 平台租户] + summary: 校验客户联系电话唯一性 + parameters: + - {name: contactPhone, in: query, required: true, schema: {type: string}} + - {name: excludeTenantId, in: query, required: false, schema: {type: integer, format: int64}} + responses: + '200': + description: 返回联系电话是否可作为登录用户名 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {type: boolean} + '403': {$ref: '#/components/responses/Forbidden'} /api/platform/v1/iam/tenants: get: tags: [Platform Tenant / 平台租户] @@ -798,7 +817,7 @@ components: properties: tenantId: {type: string, pattern: '^\d+$', example: '190000000000000001'} administratorCreated: {type: boolean, description: 本次审核是否新建企业管理员} - administratorUsername: {type: string, nullable: true, example: merchant_190000000000000001_k3mnpqrs} + administratorUsername: {type: string, nullable: true, example: '13800138000'} temporaryPassword: {type: string, format: password, nullable: true, description: 一次性初始密码,仅新建管理员时返回} TenantApiResponse: allOf: diff --git a/iam/src/test/java/com/baiyue/iam/client/CustomerDimensionRuleClientContractTest.java b/iam/src/test/java/com/baiyue/iam/client/CustomerDimensionRuleClientContractTest.java new file mode 100644 index 000000000..f6a0a355e --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/client/CustomerDimensionRuleClientContractTest.java @@ -0,0 +1,27 @@ +package com.baiyue.iam.client; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; + +import static org.assertj.core.api.Assertions.assertThat; + +class CustomerDimensionRuleClientContractTest { + @Test + void usesTheFirstlegInternalDimensionConfigContract() throws Exception { + FeignClient feign = CustomerDimensionRuleClient.class.getAnnotation(FeignClient.class); + PostMapping initialize = CustomerDimensionRuleClient.class + .getMethod("initialize", String.class, + CustomerDimensionRuleClient.InitializationRequest.class) + .getAnnotation(PostMapping.class); + DeleteMapping compensate = CustomerDimensionRuleClient.class + .getMethod("compensate", String.class, long.class, String.class) + .getAnnotation(DeleteMapping.class); + + assertThat(feign.name()).isEqualTo("firstleg-service"); + assertThat(feign.path()).isEqualTo("/internal/firstleg/v1/customer-dimension-configs"); + assertThat(initialize.value()).containsExactly("/defaults"); + assertThat(compensate.value()).containsExactly("/defaults"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionConfigPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionConfigPermissionSqlContractTest.java new file mode 100644 index 000000000..a5106e2f4 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionConfigPermissionSqlContractTest.java @@ -0,0 +1,40 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class CustomerDimensionConfigPermissionSqlContractTest { + @Test + void declaresIndependentPlatformViewAndEditPermissions() throws Exception { + String sql = new ClassPathResource("db/customer-dimension-config-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql) + .contains("(5102111, 0, 'PLATFORM', 'BUTTON', 5100010") + .contains("(5102112, 0, 'PLATFORM', 'BUTTON', 5100010") + .contains("'firstleg:dimension-config:view'") + .contains("'firstleg:dimension-config:edit'") + .contains("SIGNAL SQLSTATE '45000'") + .contains("id = 5102111 AND permission_code <> 'firstleg:dimension-config:view'") + .contains("permission_code = 'firstleg:dimension-config:edit' AND id <> 5102112"); + } + + @Test + void resolvesRoleGrantsByBusinessKeys() throws Exception { + String sql = new ClassPathResource("db/customer-dimension-config-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql) + .contains("SELECT role.id, permission.id, role.tenant_id") + .contains("role.role_code = 'PLATFORM_ADMIN'") + .contains("permission.permission_code IN (") + .contains("role.role_code = 'ORDER_OPERATOR'") + .contains("permission.permission_code = 'firstleg:dimension-config:view'") + .doesNotContain("SELECT 4000001") + .doesNotContain("SELECT 4000003"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java new file mode 100644 index 000000000..cdf4d9164 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java @@ -0,0 +1,138 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.client.CustomerDimensionRuleClient; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(CustomerDimensionRuleInitializationService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class CustomerDimensionRuleInitializationServiceTest { + @MockBean private CustomerDimensionRuleClient client; + @Resource private CustomerDimensionRuleInitializationService service; + + @AfterEach + void clearSynchronization() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + void retriesWithOneStableKeyAndReturnsTheIdempotentResult() { + when(client.initialize(anyString(), any())) + .thenThrow(new RuntimeException("timeout")) + .thenReturn(ApiResponse.failure(ResultCode.SERVICE_UNAVAILABLE, + "temporarily unavailable", "request-1", "trace-1")) + .thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, false))); + + var result = service.initialize(10001L, 9001L); + + assertFalse(result.created()); + ArgumentCaptor requests = + ArgumentCaptor.forClass(CustomerDimensionRuleClient.InitializationRequest.class); + verify(client, times(3)).initialize(anyString(), requests.capture()); + assertEquals(List.of("iam-customer-create:10001", "iam-customer-create:10001", + "iam-customer-create:10001"), + requests.getAllValues().stream().map( + CustomerDimensionRuleClient.InitializationRequest::initializationKey).toList()); + assertEquals(List.of(9001L, 9001L, 9001L), requests.getAllValues().stream() + .map(CustomerDimensionRuleClient.InitializationRequest::operatorId).toList()); + assertEquals(List.of(10001L, 10001L, 10001L), requests.getAllValues().stream() + .map(CustomerDimensionRuleClient.InitializationRequest::tenantId).toList()); + } + + @Test + void reportsServiceUnavailableAfterThreeFailedAttemptsAndTriesCompensation() { + when(client.initialize(anyString(), any())).thenThrow(new RuntimeException("timeout")); + when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null)); + + BizException failure = assertThrows(BizException.class, () -> service.initialize(10001L, 9001L)); + + assertEquals(ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode()); + verify(client, times(3)).initialize(anyString(), any()); + verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001"); + } + + @Test + void localRollbackCompensatesOnlyAConfigurationCreatedByThisRegistration() { + when(client.initialize(anyString(), any())) + .thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, true))); + when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null)); + TransactionSynchronizationManager.initSynchronization(); + + var result = service.initialize(10001L, 9001L); + List synchronizations = + TransactionSynchronizationManager.getSynchronizations(); + TransactionSynchronizationManager.clearSynchronization(); + synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)); + + assertTrue(result.created()); + verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001"); + } + + @Test + void localRollbackDoesNotDeleteAConfigurationThatAlreadyExisted() { + when(client.initialize(anyString(), any())) + .thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, false))); + TransactionSynchronizationManager.initSynchronization(); + + service.initialize(10001L, 9001L); + List synchronizations = + TransactionSynchronizationManager.getSynchronizations(); + TransactionSynchronizationManager.clearSynchronization(); + synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)); + + verify(client, never()).compensate(anyString(), anyLong(), anyString()); + } + + @Test + void responseTimeoutThenIdempotentSuccessStillCompensatesOnLocalRollback() { + when(client.initialize(anyString(), any())) + .thenThrow(new RuntimeException("response timeout")) + .thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, true))); + when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null)); + TransactionSynchronizationManager.initSynchronization(); + + service.initialize(10001L, 9001L); + List synchronizations = + TransactionSynchronizationManager.getSynchronizations(); + TransactionSynchronizationManager.clearSynchronization(); + synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)); + + verify(client, times(2)).initialize(anyString(), any()); + verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001"); + } + + @Test + void committedClientRegistrationCanBeCompensatedWithItsDeterministicKey() { + when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null)); + + service.compensateRegistration(10001L); + + verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java index 59644c2f1..7b2071e9d 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -39,6 +39,7 @@ class MerchantOnboardingServiceTest { @MockBean private IamTenantAddressMapper addresses; @MockBean private IamTenantQualificationMapper qualifications; @MockBean private IamTenantReviewMapper reviews; + @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @Resource private MerchantOnboardingService service; @Test void clientRegistrationCreatesOwnerAndAssignsGlobalMerchantOwnerRole() { @@ -63,6 +64,7 @@ class MerchantOnboardingServiceTest { verify(users).insert(user.capture()); verify(memberships).insert(membership.capture()); verify(membershipRoles).insert(roleGrant.capture()); + verify(dimensionRuleInitialization).initialize(result.tenantId(), 9001L); assertEquals("PROFILE_REQUIRED", result.tenantStatus()); assertEquals(result.tenantId(), tenant.getValue().getId()); @@ -104,6 +106,7 @@ class MerchantOnboardingServiceTest { service.compensateRegistration(9001L); + verify(dimensionRuleInitialization).compensateRegistration(10001L); verify(membershipRoles).delete(any()); verify(memberships).deleteById(30001L); verify(addresses).delete(any()); @@ -111,6 +114,28 @@ class MerchantOnboardingServiceTest { verify(tenants).deleteById((java.io.Serializable) Long.valueOf(10001L)); } + @Test + void clientRegistrationFailsWhenDefaultDimensionRuleCannotBeInitialized() { + IamRole ownerRole = new IamRole(); + ownerRole.setId(4000100L); + ownerRole.setTenantId(0L); + ownerRole.setAppCode("MERCHANT"); + ownerRole.setRoleCode("MERCHANT_OWNER"); + ownerRole.setStatus("ACTIVE"); + when(roles.selectOne(any())).thenReturn(ownerRole); + org.mockito.Mockito.doThrow(new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "初始化失败")) + .when(dimensionRuleInitialization).initialize( + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong()); + + var failure = assertThrows(com.baiyue.common.core.exception.BizException.class, + () -> service.register(new MerchantRegistrationRequest( + 9001L, "注册人", "测试企业", "张三", "13800138000", + "owner@example.com", "CN", "深圳市宝安区"))); + + assertEquals(com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode()); + } + @Test void compensationRefusesAfterQualificationUpload() { @@ -136,4 +161,34 @@ class MerchantOnboardingServiceTest { verify(users, never()).deleteById((java.io.Serializable) any()); verify(tenants, never()).deleteById((java.io.Serializable) any()); } + + @Test + void compensationKeepsIamRegistrationWhenDimensionRuleCompensationFails() { + IamUser user = new IamUser(); + user.setId(20001L); + user.setAuthUserId(9001L); + IamMembership membership = new IamMembership(); + membership.setId(30001L); + membership.setTenantId(10001L); + membership.setUserId(20001L); + membership.setMemberType("OWNER"); + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); + tenant.setStatus("PROFILE_REQUIRED"); + when(users.selectOne(any())).thenReturn(user); + when(memberships.selectList(any())).thenReturn(java.util.List.of(membership)); + when(tenants.selectOne(any())).thenReturn(tenant); + when(qualifications.selectCount(any())).thenReturn(0L); + when(reviews.selectCount(any())).thenReturn(0L); + org.mockito.Mockito.doThrow(new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "补偿失败")) + .when(dimensionRuleInitialization).compensateRegistration(10001L); + + assertThrows(com.baiyue.common.core.exception.BizException.class, + () -> service.compensateRegistration(9001L)); + + verify(membershipRoles, never()).delete(any()); + verify(users, never()).deleteById((java.io.Serializable) any()); + verify(tenants, never()).deleteById((java.io.Serializable) any()); + } } diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java index b0bd881f1..9f77673a7 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java @@ -76,7 +76,7 @@ class TenantAdministratorProvisioningServiceTest { var result = service.initialize(context()); assertTrue(result.created()); - assertTrue(result.username().matches("^merchant_190000000000000001_[a-z]{8}$")); + assertEquals("13800138000", result.username()); assertNotNull(result.temporaryPassword()); assertEquals(16, result.temporaryPassword().length()); assertTrue(result.temporaryPassword().matches("^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z0-9]+$")); @@ -208,23 +208,16 @@ class TenantAdministratorProvisioningServiceTest { } @Test - void usernameConflictGeneratesAnotherUsernameBeforeCreatingOwner() { + void usernameConflictStopsProvisioningInsteadOfChangingTheContactPhoneUsername() { org.mockito.Mockito.doReturn(ApiResponse.failure(com.baiyue.common.core.api.ResultCode.CONFLICT, - "登录账号已存在", null, null)) - .doAnswer(invocation -> { - AuthAccountClient.CreateAccountRequest request = invocation.getArgument(1); - return ApiResponse.success(new AuthAccountClient.AccountSummary( - request.accountId(), request.username(), request.displayName(), "ACTIVE")); - }).when(auth).create(anyString(), any()); + "登录账号已存在", null, null)).when(auth).create(anyString(), any()); - var result = service.initialize(context()); + var exception = assertThrows(com.baiyue.common.core.exception.BizException.class, + () -> service.initialize(context())); - assertTrue(result.created()); - ArgumentCaptor accounts = - ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class); - verify(auth, times(2)).create(eq("internal-token"), accounts.capture()); - assertFalse(accounts.getAllValues().get(0).username().equals(accounts.getAllValues().get(1).username())); - assertEquals(accounts.getAllValues().get(1).username(), result.username()); + assertEquals(com.baiyue.common.core.api.ResultCode.CONFLICT, exception.getResultCode()); + verify(auth).create(eq("internal-token"), any()); + verify(members, never()).insertUser(any()); } private TenantApprovalInitializer.ApprovalContext context() { diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java index 0194bb403..9cc3b8ef1 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java @@ -1,5 +1,6 @@ package com.baiyue.iam.service; +import com.baiyue.iam.client.AuthAccountClient; import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.repository.ProfileResourceRepository; import com.baiyue.iam.repository.TenantRepository; @@ -20,7 +21,9 @@ import static org.mockito.Mockito.when; class TenantCustomerExportTest { @MockBean private TenantRepository repository; @MockBean private ProfileResourceRepository profileRepository; + @MockBean private AuthAccountClient authAccountClient; @MockBean private TenantApprovalInitializationCoordinator approvalInitialization; + @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @Resource private TenantService service; @Test void returnsUnmaskedCustomerRowsAndAdvancingCursor() { diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index 12d3ca2cf..cd244dca3 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -48,6 +48,7 @@ class TenantServiceTest { @MockBean private MemberRepository members; @MockBean private IamRoleMapper roles; @MockBean private AuthAccountClient authClient; + @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @SpyBean private TenantAdministratorProvisioningService provisioning; @Resource private TenantService service; @@ -92,7 +93,7 @@ class TenantServiceTest { when(repository.findTenant(10001L)).thenReturn(tenant); var updated = service.updateMerchant(10001L, - new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13900139000", "alice@example.com", + new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", "alice@example.com", "专业版", "alice_wechat", "Sales Zhang", 45, "LIMITED_COMPANY", LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区示例路 1 号", "一期重点客户")); var disabled = service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED")); @@ -113,6 +114,29 @@ class TenantServiceTest { verify(repository, org.mockito.Mockito.times(2)).saveTenant(tenant); } + @Test + void activeCustomerCannotChangeTheContactPhoneUsedAsItsLoginUsername() { + IamTenant tenant = merchant(10001L, "ACTIVE"); + when(repository.findTenant(10001L)).thenReturn(tenant); + + BizException exception = assertThrows(BizException.class, () -> service.updateMerchant(10001L, + new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13900139000", null, + "基础版", null, null, 30, null, null, null, "CNY", null, null))); + + assertEquals(ResultCode.CONFLICT, exception.getResultCode()); + assertEquals("13800138000", tenant.getContactPhone()); + verify(repository, never()).saveTenant(tenant); + } + + @Test + void contactPhoneAvailabilityAlsoChecksTheAuthUsernameFactSource() { + when(authClient.availability("internal-token", "13800138000")) + .thenReturn(com.baiyue.common.core.api.ApiResponse.success(false)); + + assertEquals(false, service.contactPhoneAndUsernameAvailable("13800138000", null)); + verify(authClient).availability("internal-token", "13800138000"); + } + @Test void platformCannotBypassReviewToActivatePendingTenant() { when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "PENDING")); @@ -154,9 +178,23 @@ class TenantServiceTest { assertEquals("基础版", created.servicePackage()); verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy()) && Long.valueOf(9001L).equals(tenant.getUpdatedBy()))); + verify(dimensionRuleInitialization).initialize(created.id(), 9001L); verify(repository, never()).insertReview(any()); } + @Test + void platformRegistrationFailsWhenDefaultDimensionRuleCannotBeInitialized() { + doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "初始化失败")) + .when(dimensionRuleInitialization).initialize(any(Long.class), any(Long.class)); + + BizException failure = assertThrows(BizException.class, () -> service.register(new RegisterRequest( + "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", + "alice@example.com", "基础版", null, null, 30, null, + null, null, "CNY", null, null), 9001L)); + + assertEquals(ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode()); + } + @Test void platformRegistrationAlwaysCreatesTheDraftWithoutACreditCode() { 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 d5f55cec2..4588f7ddd 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, 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..6aa42a878 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().replaceAll("[\\p{Cc}\\p{Cf}]+", " ").strip(); + 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..baa90465c 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(); + } +} diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java index 9a58afa5b..e0f6053fa 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java @@ -32,7 +32,7 @@ public interface FirstlegInboundClient { long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName, Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName, LocalDateTime expectedArrivalAt, String businessType, String channelCode, String channelName, - Long carrierId, String carrierName, String taxMode, String containerMode, + Long carrierId, String carrierName, String taxMode, String pricingTaxMode, String containerMode, Boolean customsInspection, String destinationType, Long destinationWarehouseId, String destinationCode, String destinationName, String publicRemark, String expressCompanyName, String expressTrackingNo diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java new file mode 100644 index 000000000..3953bbc3d --- /dev/null +++ b/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java @@ -0,0 +1,17 @@ +package com.baiyue.order.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@FeignClient(name = "firstleg-service", contextId = "orderFirstlegPricingClient", + url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/pricing") +public interface FirstlegPricingClient { + @PostMapping("/channels/validate") + ApiResponse validateChannel(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody ChannelValidationRequest request); + + record ChannelValidationRequest(long tenantId, long originWarehouseId, String channelCode) {} +} diff --git a/order/src/main/java/com/baiyue/order/client/ProductImageClient.java b/order/src/main/java/com/baiyue/order/client/ProductImageClient.java new file mode 100644 index 000000000..c4c7cf428 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/client/ProductImageClient.java @@ -0,0 +1,19 @@ +package com.baiyue.order.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; + +@FeignClient(name = "file-service", contextId = "orderProductImageClient", + url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/product-images") +public interface ProductImageClient { + @GetMapping("/{tenantId}/{fileId}") + ApiResponse validate( + @RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, + @PathVariable long fileId); + + record FileMetadata(Long fileId, String fileName, String contentType, Long sizeBytes) {} +} diff --git a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java index 57fe17a98..36e7a3651 100644 --- a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java +++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java @@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.RestController; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.util.List; /** 供头程履约服务校验入仓单所关联订单货物明细。 */ @RestController @@ -51,6 +52,22 @@ public class InternalOrderCargoController { cargo.getNetWeightKg(), cargo.getCargoRemark())); } + @GetMapping("/{tenantId}/{orderId}") + public ApiResponse> listForCartonLabels( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long tenantId, + @PathVariable long orderId) { + requireToken(token); + if (tenantId <= 0 || orderId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "租户和订单ID必须大于0"); + } + return ApiResponse.success(orders.findCargo(tenantId, orderId).stream() + .map(cargo -> new CargoLabelBinding(cargo.getId(), cargo.getOrderId(), cargo.getItemNo(), + cargo.getCargoName(), cargo.getPackageCount(), cargo.getLengthCm(), cargo.getWidthCm(), + cargo.getHeightCm())) + .toList()); + } + private void requireToken(String token) { if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); @@ -70,4 +87,9 @@ public class InternalOrderCargoController { originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null); } } + + public record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, + Integer packageCount, java.math.BigDecimal lengthCm, + java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) { + } } diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 678c06aaa..1fa1b7c87 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -149,7 +149,7 @@ public class PlatformFirstLegOrderController { } @BusinessAudit(event = "ORDER_APPROVE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Approve order / 审核通过") - @Operation(summary = "Approve / 审核通过", description = "Approves a submitted order without requiring carrier assignment and records its status history. / 审核通过已提交订单,不要求预先指派承运商,并记录状态历史。") + @Operation(summary = "Approve / 审核通过", description = "Approves a submitted order, advances it to measurement, and records its status history. / 审核通过已提交订单,进入待测量阶段并记录状态历史。") @PostMapping("/{id}/approve") @PreAuthorize("hasAuthority('platform:order:approve')") public ApiResponse approve(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java index 153fb394f..e56713a34 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -56,11 +56,13 @@ public final class OrderRequests { @NotBlank @Size(max = 128) String receivingWarehouseName, @NotBlank @Size(max = 64) String channelCode, @NotBlank @Size(max = 128) String channelName, - @Schema(description = "Carrier ID snapshot; optional until the order channel resolves a carrier / 承运商 ID 快照,渠道明确承运商后可传") + @Schema(description = "Legacy internal transport supplier snapshot; new order clients should omit it / 历史内部运输供应商快照,新订单调用方应留空") @Min(1) Long carrierId, - @Schema(description = "Carrier name snapshot; optional until the order channel resolves a carrier / 承运商名称快照,渠道明确承运商后可传", maxLength = 128) + @Schema(description = "Legacy internal transport supplier name snapshot; new order clients should omit it / 历史内部运输供应商名称快照,新订单调用方应留空", maxLength = 128) @Size(max = 128) String carrierName, @NotBlank @Pattern(regexp = "BUY_DECLARATION|TAX_REFUND") String taxMode, + @Schema(description = "Estimate pricing tax mode; independent from taxMode / 参考报价包税维度,独立于 taxMode", allowableValues = {"TAX_INCLUDED", "TAX_EXCLUDED"}) + @NotBlank @Pattern(regexp = "TAX_INCLUDED|TAX_EXCLUDED") String pricingTaxMode, @Pattern(regexp = "FULL_CONTAINER|LCL") String containerMode, @NotNull Boolean customsInspection, @NotBlank @Pattern(regexp = "FBA|OVERSEAS|PRIVATE") String destinationType, @@ -74,13 +76,6 @@ public final class OrderRequests { } } - @Schema(name = "FirstLegOrderCarrierRequest", description = "Platform carrier assignment / 平台指派承运商") - public record CarrierRequest( - @Schema(description = "Active public carrier ID / 启用的公共承运商 ID", example = "193000000000300001", requiredMode = Schema.RequiredMode.REQUIRED) - @NotNull @Min(1) Long carrierId - ) { - } - @Schema(name = "FirstLegOrderParty", description = "Sender or receiver contact and address / 发货方或收货方联系人及地址") public record Party( @Schema(description = "Company name / 公司名称", example = "Example Logistics LLC", maxLength = 128, requiredMode = Schema.RequiredMode.REQUIRED) @@ -140,7 +135,13 @@ public final class OrderRequests { @Size(max = 128) String material, @Size(max = 128) String usage, @Size(max = 64) String model, - @Size(max = 64) String brand + @Size(max = 64) String brand, + @DecimalMin("0.01") BigDecimal productLengthCm, + @DecimalMin("0.01") BigDecimal productWidthCm, + @DecimalMin("0.01") BigDecimal productHeightCm, + @Size(max = 64) String shipmentId, + @Size(max = 64) String referenceId, + @Schema(description = "Product image file ID / 产品图片文件ID") Long productImageFileId ) { } diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java index 46544709c..5776b1f4d 100644 --- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java @@ -21,6 +21,7 @@ public final class UnifiedOrderResponse { Integer totalPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm, MeasurementSummary measurement, PreloadSummary preload, LoadingSummary loading, DeliverySummary delivery, TransportSummary transport, + String customerRemark, String latestInternalNote, LocalDateTime createdAt, LocalDateTime updatedAt ) {} diff --git a/order/src/main/java/com/baiyue/order/entity/OrderCargoEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderCargoEntity.java index e09ed5295..08a5236d4 100644 --- a/order/src/main/java/com/baiyue/order/entity/OrderCargoEntity.java +++ b/order/src/main/java/com/baiyue/order/entity/OrderCargoEntity.java @@ -12,7 +12,7 @@ import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus. * 申报价值及危险品标识等货物信息。

*/ @TableName("ord_order_cargo") public class OrderCargoEntity extends BaseEntity { - @TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private Integer itemNo; private String cargoName; private String hsCode; private String packageType; private Integer packageCount; private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; private BigDecimal declaredValue; private String currency; private Boolean dangerousGoods; private String cargoRemark; private String skuNo; private String upcNo; private String cargoNameEn; private String customsHsCode; private Integer unitsPerPackage; private Integer totalQuantity; private BigDecimal netWeightKg; private BigDecimal unitDeclaredValue; private String material; private String productUsage; private String model; private String brand; + @TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private Integer itemNo; private String cargoName; private String hsCode; private String packageType; private Integer packageCount; private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; private BigDecimal declaredValue; private String currency; private Boolean dangerousGoods; private String cargoRemark; private String skuNo; private String upcNo; private String cargoNameEn; private String customsHsCode; private Integer unitsPerPackage; private Integer totalQuantity; private BigDecimal netWeightKg; private BigDecimal unitDeclaredValue; private String material; private String productUsage; private String model; private String brand; private BigDecimal productLengthCm; private BigDecimal productWidthCm; private BigDecimal productHeightCm; private String shipmentId; private String referenceId; private Long productImageFileId; } diff --git a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java index 6f0262346..fbb773243 100644 --- a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java +++ b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java @@ -42,6 +42,7 @@ public class OrderEntity extends BaseEntity { private Long carrierId; private String carrierName; private String taxMode; + private String pricingTaxMode; private String containerMode; private Boolean customsInspection; private String destinationType; diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index e1f3fd187..a8e1e885b 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -219,6 +219,23 @@ public class OrderRepository { .orderByDesc(OrderStatusHistoryEntity::getOccurredAt)); } + public List findByIds(Long tenantId, List orderIds) { + if (orderIds.isEmpty()) return List.of(); + return orderMapper.selectList(new LambdaQueryWrapper() + .eq(tenantId != null, OrderEntity::getTenantId, tenantId) + .in(OrderEntity::getId, orderIds)); + } + + public List findNotesByOrderIds(Long tenantId, List orderIds) { + if (orderIds.isEmpty()) return List.of(); + return noteMapper.selectList(new LambdaQueryWrapper() + .eq(tenantId != null, OrderInternalNoteEntity::getTenantId, tenantId) + .in(OrderInternalNoteEntity::getOrderId, orderIds) + .orderByAsc(OrderInternalNoteEntity::getOrderId) + .orderByDesc(OrderInternalNoteEntity::getCreatedAt) + .orderByDesc(OrderInternalNoteEntity::getId)); + } + public Map countByTenantIds(List tenantIds) { if (tenantIds.isEmpty()) return Map.of(); var rows = orderMapper.selectMaps(new QueryWrapper() diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 90632951c..2ca523c5d 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -22,6 +22,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.stream.Collectors; @@ -31,6 +32,8 @@ public class OrderApplicationService { @Resource private ObjectMapper json; @Resource private OrderEligibilityGuard eligibilityGuard; @Resource private OrderMasterDataGuard masterDataGuard; + @Resource private OrderPricingChannelGuard pricingChannelGuard; + @Resource private ProductImageGuard productImageGuard; /** 创建草稿时使用数据库唯一键保证请求幂等。 */ @Transactional @@ -47,7 +50,9 @@ public class OrderApplicationService { private Long createDraftInternal(long tenantId, long userId, OrderRequests.SaveRequest request) { validatePrototypeFields(request); + productImageGuard.requireOwned(tenantId, request.cargoes()); var snapshots = masterDataGuard.validate(tenantId, request); + pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode()); OrderEntity order = new OrderEntity(); long orderId = IdWorker.getId(); order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId)); order.setStatus(OrderStatus.DRAFT); @@ -73,29 +78,18 @@ public class OrderApplicationService { updateOrderData(tenantId, userId, id, request, true); } - @Transactional - public void assignCarrier(long tenantId, long userId, long id, OrderRequests.CarrierRequest request) { - OrderEntity order = require(tenantId, id); - if (order.getStatus() != OrderStatus.SUBMITTED) { - throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "仅待审核订单可以指派承运商"); - } - var carrier = masterDataGuard.validateCarrier(request.carrierId()); - order.setCarrierId(carrier.id()); - order.setCarrierName(carrier.partnerName()); - order.setUpdatedBy(userId); - if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update"); - } - private void updateOrderData(long tenantId, long userId, long id, OrderRequests.SaveRequest request, boolean allowSubmitted) { OrderEntity order = require(tenantId, id); validatePrototypeFields(request); + productImageGuard.requireOwned(tenantId, request.cargoes()); var snapshots = masterDataGuard.validate(tenantId, request); OrderStatus before = order.getStatus(); if (before != OrderStatus.DRAFT && before != OrderStatus.REJECTED && !(allowSubmitted && before == OrderStatus.SUBMITTED)) { throw new BizException("当前订单状态不允许编辑订单资料"); } + pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode()); apply(order, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId); if (before == OrderStatus.REJECTED) order.setInboundCode(null); if (before == OrderStatus.SUBMITTED) { @@ -118,10 +112,7 @@ public class OrderApplicationService { validatePersistedPrototypeFields(tenantId, order); masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(), order.getDestinationWarehouseId(), order.getDestinationType()); - } - if (target == OrderStatus.APPROVED && order.getCarrierId() != null) { - var carrier = masterDataGuard.validateCarrier(order.getCarrierId()); - order.setCarrierName(carrier.partnerName()); + pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(), order.getChannelCode()); } if ((target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED) && !StringUtils.hasText(reason)) { throw new BizException("Reason is required"); @@ -201,14 +192,19 @@ public class OrderApplicationService { throw new BizException("FBA orders require Amazon warehouse code, Shipment ID and Reference ID"); } for (var cargo : request.cargoes()) { - if (!StringUtils.hasText(cargo.skuNo()) || !StringUtils.hasText(cargo.upcNo())) { - throw new BizException("FBA cargo requires SKU NO. and UPC NO."); + if (!StringUtils.hasText(cargo.skuNo()) || !StringUtils.hasText(cargo.upcNo()) + || !StringUtils.hasText(cargo.shipmentId()) || !StringUtils.hasText(cargo.referenceId())) { + throw new BizException("FBA cargo requires SKU NO., UPC NO., Shipment ID and Reference ID."); } } } } private void validatePersistedPrototypeFields(long tenantId, OrderEntity order) { + if (!"TAX_INCLUDED".equals(order.getPricingTaxMode()) + && !"TAX_EXCLUDED".equals(order.getPricingTaxMode())) { + throw new BizException("请选择包税或不包税报价"); + } if (!StringUtils.hasText(order.getPickupMethod()) || order.getReceivingWarehouseId() == null || !StringUtils.hasText(order.getReceivingWarehouseName()) || !StringUtils.hasText(order.getChannelCode()) || !StringUtils.hasText(order.getChannelName()) @@ -231,8 +227,10 @@ public class OrderApplicationService { throw new BizException("FBA orders require Amazon warehouse code, Shipment ID and Reference ID"); } for (var cargo : cargoes) { - if (!StringUtils.hasText(cargo.getSkuNo()) || !StringUtils.hasText(cargo.getUpcNo())) { - throw new BizException("FBA cargo requires SKU NO. and UPC NO."); + if (!StringUtils.hasText(cargo.getSkuNo()) || !StringUtils.hasText(cargo.getUpcNo()) + || !StringUtils.hasText(cargo.getShipmentId()) + || !StringUtils.hasText(cargo.getReferenceId())) { + throw new BizException("FBA cargo requires SKU NO., UPC NO., Shipment ID and Reference ID."); } } } @@ -347,12 +345,15 @@ public class OrderApplicationService { order.setReceivingWarehouseName(snapshots.receiving().warehouse().warehouseName()); order.setReceivingWarehouseAddress(formatAddress(snapshots.receiving())); order.setReceivingTime(formatReceivingTime(snapshots.receiving())); - order.setChannelCode(request.channelCode()); + // The price-book catalog is case-insensitive at the boundary, but orders + // keep one canonical code so later estimation and audit lookups are stable. + order.setChannelCode(request.channelCode().trim().toUpperCase(Locale.ROOT)); order.setChannelName(request.channelName()); order.setCarrierId(request.carrierId()); order.setCarrierName(request.carrierId() == null || !StringUtils.hasText(request.carrierName()) ? null : request.carrierName().trim()); order.setTaxMode(request.taxMode()); + order.setPricingTaxMode(request.pricingTaxMode()); order.setContainerMode(request.containerMode()); order.setCustomsInspection(request.customsInspection()); order.setDestinationType(request.destinationType()); order.setAmazonWarehouseCode("FBA".equals(request.destinationType()) && snapshots.destination() != null @@ -389,6 +390,10 @@ public class OrderApplicationService { item.setTotalQuantity(cargo.totalQuantity()); item.setNetWeightKg(cargo.netWeightKg()); item.setUnitDeclaredValue(cargo.unitDeclaredValue()); item.setMaterial(cargo.material()); item.setProductUsage(cargo.usage()); item.setModel(cargo.model()); item.setBrand(cargo.brand()); + item.setProductLengthCm(cargo.productLengthCm()); item.setProductWidthCm(cargo.productWidthCm()); + item.setProductHeightCm(cargo.productHeightCm()); item.setShipmentId(cargo.shipmentId()); + item.setReferenceId(cargo.referenceId()); + item.setProductImageFileId(cargo.productImageFileId()); item.setCreatedBy(userId); item.setUpdatedBy(userId); repository.insertCargo(item); } if (request.sender() != null) saveParty(tenantId, userId, orderId, "SENDER", request.sender()); @@ -422,7 +427,7 @@ public class OrderApplicationService { event.setPublishStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now()); repository.insertOutbox(event); } - private FirstlegInboundClient.OrderApprovedInboundCommand inboundCommand(OrderEntity order) { + static FirstlegInboundClient.OrderApprovedInboundCommand inboundCommand(OrderEntity order) { return new FirstlegInboundClient.OrderApprovedInboundCommand( order.getTenantId(), order.getId(), order.getOrderNo(), order.getInboundCode(), order.getCustomerId(), order.getCustomerName(), @@ -430,7 +435,7 @@ public class OrderApplicationService { order.getDestinationWarehouseName(), order.getExpectedShipDate() == null ? null : order.getExpectedShipDate().atStartOfDay(), order.getDestinationType() + "_" + order.getTransportMode(), order.getChannelCode(), order.getChannelName(), order.getCarrierId(), order.getCarrierName(), - "BUY_DECLARATION".equals(order.getTaxMode()) ? "TAX_INCLUDED" : "TAX_EXCLUDED", + order.getTaxMode(), order.getPricingTaxMode(), order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(), order.getDestinationWarehouseId(), order.getAmazonWarehouseCode(), order.getDestinationWarehouseName(), order.getCustomerRemark(), order.getExpressCompany(), order.getExpressTrackingNo()); diff --git a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java index 5cc25cfd9..5c9f13331 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java +++ b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java @@ -39,12 +39,28 @@ public class OrderInboundOutboxPublisher { failPermanently(event, "invalid event payload"); continue; } + if (command == null) { + failPermanently(event, "empty event payload"); + continue; + } if (event.getTenantId() == null || event.getAggregateId() == null || event.getTenantId() != command.tenantId() || event.getAggregateId() != command.orderId()) { failPermanently(event, "event ownership does not match payload"); continue; } + if (incomplete(command)) { + var order = repository.findOwned(event.getTenantId(), event.getAggregateId()); + if (order == null) { + failPermanently(event, "order snapshot is unavailable"); + continue; + } + command = OrderApplicationService.inboundCommand(order); + if (incomplete(command)) { + failPermanently(event, "rebuilt order snapshot is incomplete"); + continue; + } + } try { ApiResponse response = client.create(internalToken, command); if (response == null || !response.successful()) { @@ -57,6 +73,13 @@ public class OrderInboundOutboxPublisher { } } + private boolean incomplete(FirstlegInboundClient.OrderApprovedInboundCommand command) { + return command.orderNo() == null || command.orderNo().isBlank() + || command.inboundCode() == null || command.inboundCode().isBlank() + || command.customerName() == null || command.customerName().isBlank() + || command.warehouseId() == null || command.warehouseId() <= 0; + } + private void failPermanently(OutboxEventEntity event, String reason) { log.warn("Order outbox event rejected: reason={}, eventId={}, tenantId={}, aggregateId={}, eventType={}", reason, event.getId(), event.getTenantId(), event.getAggregateId(), event.getEventType()); diff --git a/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java b/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java new file mode 100644 index 000000000..4ccf3ed7c --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java @@ -0,0 +1,42 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.client.FirstlegPricingClient; +import feign.FeignException; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class OrderPricingChannelGuard { + @Resource private FirstlegPricingClient client; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + public void requireAvailable(long tenantId, long originWarehouseId, String channelCode) { + try { + ApiResponse response = client.validateChannel(internalToken, + new FirstlegPricingClient.ChannelValidationRequest(tenantId, originWarehouseId, channelCode)); + if (response == null) { + throw unavailable(); + } + if (!response.successful()) { + ResultCode code = ResultCode.of(response.code()); + String message = response.message() == null || response.message().isBlank() + ? code.message() : response.message(); + throw new BizException(code, message); + } + } catch (BizException exception) { + throw exception; + } catch (FeignException exception) { + throw unavailable(); + } catch (RuntimeException exception) { + throw unavailable(); + } + } + + private BizException unavailable() { + return new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程价格渠道校验服务暂时不可用"); + } +} diff --git a/order/src/main/java/com/baiyue/order/service/ProductImageGuard.java b/order/src/main/java/com/baiyue/order/service/ProductImageGuard.java new file mode 100644 index 000000000..a70a1026c --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/ProductImageGuard.java @@ -0,0 +1,58 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.client.ProductImageClient; +import com.baiyue.order.dto.OrderRequests; +import feign.FeignException; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Service +public class ProductImageGuard { + @Resource private ProductImageClient client; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + public void requireOwned(long tenantId, List cargoes) { + Set validated = new HashSet<>(); + for (OrderRequests.Cargo cargo : cargoes) { + Long fileId = cargo.productImageFileId(); + if (fileId == null || !validated.add(fileId)) continue; + if (fileId <= 0) throw new BizException(ResultCode.VALIDATION_FAILED, "产品图片文件ID无效"); + validate(tenantId, fileId); + } + } + + private void validate(long tenantId, long fileId) { + try { + ApiResponse response = client.validate(internalToken, tenantId, fileId); + if (response == null) throw unavailable(); + if (!response.successful()) { + ResultCode code = ResultCode.of(response.code()); + String message = response.message() == null || response.message().isBlank() + ? code.message() : response.message(); + throw new BizException(code, message); + } + if (response.data() == null || response.data().fileId() == null + || response.data().fileId() != fileId) { + throw unavailable(); + } + } catch (BizException exception) { + throw exception; + } catch (FeignException exception) { + throw unavailable(); + } catch (RuntimeException exception) { + throw unavailable(); + } + } + + private static BizException unavailable() { + return new BizException(ResultCode.SERVICE_UNAVAILABLE, "产品图片校验服务暂时不可用"); + } +} diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index b4016f935..db51e0aa1 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -9,6 +9,7 @@ import com.baiyue.order.dto.OrderDetailResponse; import com.baiyue.order.dto.UnifiedOrderQuery; import com.baiyue.order.dto.UnifiedOrderResponse; import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderInternalNoteEntity; import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.repository.OrderRepository; import feign.FeignException; @@ -54,8 +55,9 @@ public class UnifiedOrderQueryService { Map inboundByOrder = "PENDING_REVIEW".equals(query.mainStatus()) ? Map.of() : summaries(tenantId, source.records().stream().map(OrderEntity::getId).toList()); + Map latestNotes = latestNotes(tenantId, source.records().stream().map(OrderEntity::getId).toList()); List rows = source.records().stream() - .map(order -> row(order, inboundByOrder.get(order.getId()))).toList(); + .map(order -> row(order, inboundByOrder.get(order.getId()), latestNotes.get(order.getId()))).toList(); return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages()); } @@ -67,7 +69,8 @@ public class UnifiedOrderQueryService { orders.findHistory(tenantId, orderId)); FirstlegUnifiedOrderClient.Detail inbound = order.getStatus() == OrderStatus.SUBMITTED ? null : firstlegDetail(tenantId, orderId); - UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary()); + String latestNote = orderDetail.internalNotes().isEmpty() ? null : orderDetail.internalNotes().get(0).getContent(); + UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary(), latestNote); List timeline = new ArrayList<>(); orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent( "ORDER", "STATUS", orderMainStatus(item.getToStatus()), item.getAction(), item.getReason(), @@ -97,7 +100,13 @@ public class UnifiedOrderQueryService { query.page(), query.size()); PageResult source = require( call(() -> firstleg.page(internalToken, request)), "统一订单履约查询响应无效"); - List records = source.records().stream().map(this::row).toList(); + List orderIds = source.records().stream().map(item -> item.inbound().orderId()).toList(); + Map orderById = orders.findByIds(tenantId, orderIds).stream() + .collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left)); + Map latestNotes = latestNotes(tenantId, orderIds); + List records = source.records().stream() + .map(summary -> row(summary, orderById.get(summary.inbound().orderId()), latestNotes.get(summary.inbound().orderId()))) + .toList(); return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages()); } @@ -116,7 +125,12 @@ public class UnifiedOrderQueryService { new FirstlegUnifiedOrderClient.DetailRequest(tenantId, orderId))), "统一订单履约详情响应无效"); } - private UnifiedOrderResponse.Row row(OrderEntity order, FirstlegUnifiedOrderClient.Summary summary) { + private Map latestNotes(Long tenantId, List orderIds) { + return orders.findNotesByOrderIds(tenantId, orderIds).stream().collect(Collectors.toMap( + OrderInternalNoteEntity::getOrderId, OrderInternalNoteEntity::getContent, (latest, ignored) -> latest)); + } + + private UnifiedOrderResponse.Row row(OrderEntity order, FirstlegUnifiedOrderClient.Summary summary, String latestInternalNote) { if (summary == null) { String mainStatus = orderMainStatus(order.getStatus()); return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), mainStatus, @@ -126,6 +140,7 @@ public class UnifiedOrderQueryService { order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null, order.getInboundCode(), order.getExpectedShipDate(), null, null, null, null, null, null, null, null, + order.getCustomerRemark(), latestInternalNote, order.getCreatedAt(), order.getUpdatedAt()); } FirstlegUnifiedOrderClient.Inbound inbound = summary.inbound(); @@ -135,10 +150,12 @@ public class UnifiedOrderQueryService { order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(), order.getExpectedShipDate(), inbound.totalPackageCount(), inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), measurement(inbound), preload(summary.preload()), loading(summary.loading()), - delivery(summary.delivery()), transport(inbound), order.getCreatedAt(), order.getUpdatedAt()); + delivery(summary.delivery()), transport(inbound), order.getCustomerRemark(), latestInternalNote, + order.getCreatedAt(), order.getUpdatedAt()); } - private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary) { + private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary, OrderEntity order, + String latestInternalNote) { var inbound = summary.inbound(); return new UnifiedOrderResponse.Row(inbound.orderId(), inbound.tenantId(), inbound.orderNo(), summary.mainStatus(), summary.nextAction(), parseOrderStatus(inbound.orderStatusSnapshot()), @@ -146,7 +163,8 @@ public class UnifiedOrderQueryService { inbound.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(), null, inbound.totalPackageCount(), inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), measurement(inbound), preload(summary.preload()), loading(summary.loading()), - delivery(summary.delivery()), transport(inbound), inbound.createdAt(), inbound.updatedAt()); + delivery(summary.delivery()), transport(inbound), order == null ? null : order.getCustomerRemark(), latestInternalNote, + inbound.createdAt(), inbound.updatedAt()); } private UnifiedOrderResponse.MeasurementSummary measurement(FirstlegUnifiedOrderClient.Inbound inbound) { diff --git a/order/src/main/resources/application.yml b/order/src/main/resources/application.yml index 17e31bf60..19677f6c6 100644 --- a/order/src/main/resources/application.yml +++ b/order/src/main/resources/application.yml @@ -33,6 +33,8 @@ baiyue: url: ${IAM_SERVICE_URL:} warehouse: url: ${WAREHOUSE_SERVICE_URL:} + file: + url: ${FILE_SERVICE_URL:} logging: file: diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index 2f8bae6d5..f8697e490 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -30,6 +30,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID快照', carrier_name VARCHAR(128) NULL COMMENT '船司名称快照', tax_mode VARCHAR(24) NULL COMMENT '买单或退税', + pricing_tax_mode VARCHAR(16) NULL COMMENT '报价包税维度:TAX_INCLUDED或TAX_EXCLUDED;独立于tax_mode', container_mode VARCHAR(24) NULL COMMENT '整柜或散货', customs_inspection TINYINT(1) NULL COMMENT '是否商检', destination_type VARCHAR(24) NULL COMMENT 'FBA/OVERSEAS/PRIVATE', @@ -110,6 +111,12 @@ CREATE TABLE IF NOT EXISTS ord_order_cargo ( product_usage VARCHAR(128) NULL COMMENT '用途', model VARCHAR(64) NULL COMMENT '型号', brand VARCHAR(64) NULL COMMENT '品牌', + product_length_cm DECIMAL(12,2) NULL COMMENT '产品长度(厘米)', + product_width_cm DECIMAL(12,2) NULL COMMENT '产品宽度(厘米)', + product_height_cm DECIMAL(12,2) NULL COMMENT '产品高度(厘米)', + shipment_id VARCHAR(64) NULL COMMENT '货物级 Amazon Shipment ID', + reference_id VARCHAR(64) NULL COMMENT '货物级 Amazon Reference ID', + product_image_file_id BIGINT UNSIGNED NULL COMMENT '文件中心产品图片逻辑ID', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', diff --git a/order/src/main/resources/db/phase1-order-extension.sql b/order/src/main/resources/db/phase1-order-extension.sql index 44cfc7162..acbbde30f 100644 --- a/order/src/main/resources/db/phase1-order-extension.sql +++ b/order/src/main/resources/db/phase1-order-extension.sql @@ -54,6 +54,21 @@ CALL phase1_add_column('ord_order_cargo', 'material', "VARCHAR(128) NULL COMMENT CALL phase1_add_column('ord_order_cargo', 'product_usage', "VARCHAR(128) NULL COMMENT 'Usage'"); CALL phase1_add_column('ord_order_cargo', 'model', "VARCHAR(64) NULL COMMENT 'Model'"); CALL phase1_add_column('ord_order_cargo', 'brand', "VARCHAR(64) NULL COMMENT 'Brand'"); +CALL phase1_add_column('ord_order_cargo', 'product_length_cm', "DECIMAL(12,2) NULL COMMENT 'Product length in centimeters'"); +CALL phase1_add_column('ord_order_cargo', 'product_width_cm', "DECIMAL(12,2) NULL COMMENT 'Product width in centimeters'"); +CALL phase1_add_column('ord_order_cargo', 'product_height_cm', "DECIMAL(12,2) NULL COMMENT 'Product height in centimeters'"); +CALL phase1_add_column('ord_order_cargo', 'shipment_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Shipment ID'"); +CALL phase1_add_column('ord_order_cargo', 'reference_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Reference ID'"); +CALL phase1_add_column('ord_order_cargo', 'product_image_file_id', "BIGINT UNSIGNED NULL COMMENT 'Product image logical file ID'"); + +-- 货物级字段上线前,FBA 编号只保存在主单;回填后提交校验可统一按每条货物执行。 +UPDATE ord_order_cargo cargo +JOIN ord_order orders ON orders.id = cargo.order_id AND orders.tenant_id = cargo.tenant_id +SET cargo.shipment_id = COALESCE(NULLIF(cargo.shipment_id, ''), orders.shipment_id), + cargo.reference_id = COALESCE(NULLIF(cargo.reference_id, ''), orders.reference_id) +WHERE orders.destination_type = 'FBA' + AND ((cargo.shipment_id IS NULL OR cargo.shipment_id = '') + OR (cargo.reference_id IS NULL OR cargo.reference_id = '')); DROP PROCEDURE phase1_add_column; diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 3bf897154..df00e1543 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: 柏悦头程订单 API description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功,其他 code 表示失败。 - version: 1.4.0 + version: 1.5.0 security: - bearerAuth: [] paths: @@ -16,6 +16,7 @@ paths: 才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。 tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询, 指定其他主状态时由 Firstleg 精确分页。旧 /orders/first-leg 接口保持兼容。 + 列表行同时返回 customerRemark(客户下单备注)和 latestInternalNote(最新一条平台内部备注)。 parameters: - {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - $ref: '#/components/parameters/UnifiedKeyword' @@ -112,7 +113,11 @@ paths: parameters: - $ref: '#/components/parameters/UnifiedOrderId' responses: - '200': {description: 成功或业务失败,按响应体 code 判断} + '200': + description: 成功或业务失败,按响应体 code 判断 + content: + application/json: + schema: {$ref: '#/components/schemas/FirstLegOrderSummary'} '403': {description: 需要 merchant:order:view 权限} /api/merchant/v1/orders/first-leg: get: @@ -138,7 +143,10 @@ paths: description: 成功或业务失败,响应体 code 为字符串 200 时成功 content: application/json: - schema: {type: object} + schema: + type: object + properties: + items: {type: array, items: {$ref: '#/components/schemas/FirstLegOrderSummary'}} post: summary: 创建订单草稿 parameters: @@ -147,7 +155,7 @@ paths: required: true content: application/json: - schema: {type: object} + schema: {$ref: '#/components/schemas/FirstLegOrderDraft'} responses: '200': {description: 成功或业务失败,按响应体 code 判断} /api/merchant/v1/orders/first-leg/{id}/submit: @@ -180,7 +188,7 @@ paths: required: true content: application/json: - schema: {type: object} + schema: {$ref: '#/components/schemas/FirstLegOrderDraft'} responses: '200': {description: 成功或业务失败,按响应体 code 判断} '403': {description: 需要 platform:order:create 权限} @@ -216,35 +224,76 @@ paths: required: true content: application/json: - schema: {type: object} + schema: {$ref: '#/components/schemas/FirstLegOrderDraft'} responses: '200': {description: 成功或业务失败,按响应体 code 判断} '403': {description: 需要 platform:order:edit 权限} - /api/platform/v1/orders/first-leg/{id}/carrier: - put: - summary: 为待审核订单指派承运商 - description: 可选的独立指派动作;服务端从公共往来单位主数据校验 CARRIER 类型并写入可信名称快照,不作为审核通过的前置条件。 - parameters: - - $ref: '#/components/parameters/OrderId' - - $ref: '#/components/parameters/TenantId' - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [carrierId] - properties: - carrierId: {type: string, pattern: '^[1-9][0-9]*$'} - responses: - '200': {description: 指派成功或业务失败,按响应体 code 判断} - '403': {description: 需要 platform:order:approve 权限} components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT + schemas: + PricingTaxMode: + type: string + enum: [TAX_INCLUDED, TAX_EXCLUDED] + description: 报价包税维度;独立于订单 taxMode 的买单/退税语义 + FirstLegOrderDraft: + type: object + required: [pricingTaxMode, cargoes] + properties: + pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'} + cargoes: + type: array + minItems: 1 + items: {$ref: '#/components/schemas/FirstLegOrderCargo'} + description: >- + 订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;pricingTaxMode + 是新建和编辑订单的必填报价维度;历史存量订单的响应字段仍允许为空。 + FirstLegOrderCargo: + type: object + required: [cargoName, packageType, packageCount, grossWeightKg, volumeCbm] + properties: + skuNo: {type: string, maxLength: 64} + cargoName: {type: string, maxLength: 128} + cargoNameEn: {type: string, maxLength: 128} + productImageFileId: {type: string, pattern: '^[1-9][0-9]*$'} + hsCode: {type: string, maxLength: 20, description: 报关 HS CODE} + customsHsCode: {type: string, maxLength: 20, description: 清关 HS CODE} + lengthCm: {type: number, minimum: 0, description: 箱规长(厘米)} + widthCm: {type: number, minimum: 0, description: 箱规宽(厘米)} + heightCm: {type: number, minimum: 0, description: 箱规高(厘米)} + unitsPerPackage: {type: integer, minimum: 1, description: 每箱数量} + packageCount: {type: integer, minimum: 1, description: 箱数} + grossWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 总毛重} + volumeCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 总体积} + totalQuantity: {type: integer, minimum: 1, description: 总数量} + netWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 总净重} + unitDeclaredValue: {type: number, minimum: 0, description: 清关申报单价(USD)} + declaredValue: {type: number, minimum: 0, description: 清关申报金额(USD)} + material: {type: string, maxLength: 128} + usage: {type: string, maxLength: 128} + model: {type: string, maxLength: 64} + brand: {type: string, maxLength: 64} + productLengthCm: {type: number, minimum: 0} + productWidthCm: {type: number, minimum: 0} + productHeightCm: {type: number, minimum: 0} + shipmentId: {type: string, maxLength: 64} + referenceId: {type: string, maxLength: 64} + upcNo: {type: string, maxLength: 64} + cargoRemark: {type: string, maxLength: 500} + packageType: {type: string, maxLength: 32} + dangerousGoods: {type: boolean} + description: >- + 原型货物明细字段。FBA 货物的 skuNo、upcNo、shipmentId、referenceId 由服务端按每条货物校验; + productImageFileId 必须属于当前登录客户。 + FirstLegOrderSummary: + type: object + properties: + orderId: {type: string, pattern: '^[1-9][0-9]*$'} + orderNo: {type: string} + pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'} parameters: UnifiedKeyword: name: keyword diff --git a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java index 11405e5a9..83cc76d23 100644 --- a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java @@ -16,6 +16,9 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; +import java.math.BigDecimal; +import java.util.List; + @SpringJUnitConfig(InternalOrderCargoController.class) @TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class InternalOrderCargoControllerTest { @@ -70,4 +73,34 @@ class InternalOrderCargoControllerTest { verify(orders).findCargo(12L, 21L, 31L); verifyNoMoreInteractions(orders); } + + @Test + void listsCargoInRepositoryOrderForStableCartonAllocation() { + OrderCargoEntity first = cargo(31L, 1, 3, "60", "40", "30"); + OrderCargoEntity second = cargo(32L, 2, 2, "50", "30", "20"); + when(orders.findCargo(11L, 21L)).thenReturn(List.of(first, second)); + + var response = controller.listForCartonLabels("internal-token", 11L, 21L); + + assertThat(response.successful()).isTrue(); + assertThat(response.data()).extracting(InternalOrderCargoController.CargoLabelBinding::cargoId) + .containsExactly(31L, 32L); + assertThat(response.data().get(0).packageCount()).isEqualTo(3); + assertThat(response.data().get(0).lengthCm()).isEqualByComparingTo("60"); + verify(orders).findCargo(11L, 21L); + } + + private OrderCargoEntity cargo(long id, int itemNo, int packageCount, + String length, String width, String height) { + OrderCargoEntity cargo = new OrderCargoEntity(); + cargo.setId(id); + cargo.setOrderId(21L); + cargo.setItemNo(itemNo); + cargo.setCargoName("货物" + itemNo); + cargo.setPackageCount(packageCount); + cargo.setLengthCm(new BigDecimal(length)); + cargo.setWidthCm(new BigDecimal(width)); + cargo.setHeightCm(new BigDecimal(height)); + return cargo; + } } diff --git a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java index 308fee7fe..9ffd224f5 100644 --- a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java +++ b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java @@ -14,28 +14,38 @@ class OrderRequestsValidationTest { @Test void carrierSnapshotIsOptionalWhenCustomerCreatesAnOrderDraft() { - assertThat(validator.validate(request(null, null))).isEmpty(); + assertThat(validator.validate(request(null, null, "TAX_INCLUDED"))).isEmpty(); } @Test void carrierSnapshotMustBeBothPresentOrBothAbsent() { - assertThat(validator.validate(request(9L, null))) + assertThat(validator.validate(request(9L, null, "TAX_INCLUDED"))) .anyMatch(violation -> violation.getMessage().contains("必须同时填写或同时留空")); - assertThat(validator.validate(request(null, "船司"))) + assertThat(validator.validate(request(null, "船司", "TAX_INCLUDED"))) .anyMatch(violation -> violation.getMessage().contains("必须同时填写或同时留空")); - assertThat(validator.validate(request(9L, "船司"))).isEmpty(); + assertThat(validator.validate(request(9L, "船司", "TAX_INCLUDED"))).isEmpty(); } - private OrderRequests.SaveRequest request(Long carrierId, String carrierName) { + @Test + void pricingTaxModeIsRequiredAndRejectsOrderTaxValues() { + assertThat(validator.validate(request(null, null, "TAX_INCLUDED"))).isEmpty(); + assertThat(validator.validate(request(null, null, null))) + .anyMatch(violation -> violation.getPropertyPath().toString().equals("pricingTaxMode")); + assertThat(validator.validate(request(null, null, "BUY_DECLARATION"))) + .anyMatch(violation -> violation.getPropertyPath().toString().equals("pricingTaxMode")); + } + + private OrderRequests.SaveRequest request(Long carrierId, String carrierName, String pricingTaxMode) { var cargo = new OrderRequests.Cargo( "货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE, null, null, null, null, null, false, null, null, null, null, - null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null); var request = new OrderRequests.SaveRequest( null, "当前企业", "SEA", "FOB", "US", 193000000000200003L, "洛杉矶仓", null, null, null, null, List.of(cargo), "SELF_EXPRESS", 193000000000200001L, "义乌仓", "SEA-US-GENERAL", "美线普货专线", carrierId, carrierName, "BUY_DECLARATION", - "FULL_CONTAINER", true, "OVERSEAS", null, null, "123456789"); + pricingTaxMode, "FULL_CONTAINER", true, "OVERSEAS", null, null, "123456789"); return request; } } diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 86d0cd632..2550e1d95 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -34,6 +34,8 @@ class OrderApplicationServiceStateTest { @MockBean private OrderRepository contextRepository; @MockBean private OrderEligibilityGuard contextEligibility; @MockBean private OrderMasterDataGuard contextMasterData; + @MockBean private OrderPricingChannelGuard contextPricingChannelGuard; + @MockBean private ProductImageGuard contextProductImageGuard; @Resource private OrderApplicationService springService; @Configuration(proxyBeanMethods = false) @@ -58,6 +60,54 @@ class OrderApplicationServiceStateTest { && order.getInboundCode() == null)); } + @Test + void creatingDraftValidatesCurrentPriceBookChannelBeforePersistence() { + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class); + OrderRequests.SaveRequest request = request(); + when(masterData.validate(10001L, request)).thenReturn(snapshots()); + when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1); + OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData); + ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing); + + service.createDraft(10001L, 2L, request, "validate-channel-on-create"); + + var ordered = inOrder(pricing, repository); + ordered.verify(pricing).requireAvailable(10001L, 8L, "CH"); + ordered.verify(repository).insertOrder(argThat(order -> + "CH".equals(order.getChannelCode()) + && "TAX_INCLUDED".equals(order.getPricingTaxMode()))); + } + + @Test + void orderStoresCanonicalChannelCodeWithoutChangingPricingTaxMode() { + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class); + OrderRequests.SaveRequest source = request(); + OrderRequests.SaveRequest request = new OrderRequests.SaveRequest( + source.customerId(), source.customerName(), source.transportMode(), source.tradeTerm(), + source.destinationCountryCode(), source.destinationWarehouseId(), source.destinationWarehouseName(), + source.expectedShipDate(), source.customerRemark(), source.sender(), source.receiver(), source.cargoes(), + source.pickupMethod(), source.receivingWarehouseId(), source.receivingWarehouseName(), + " ch ", source.channelName(), source.carrierId(), source.carrierName(), source.taxMode(), + "TAX_EXCLUDED", source.containerMode(), source.customsInspection(), source.destinationType(), + source.amazonWarehouseCode(), source.shipmentId(), source.referenceId()); + when(masterData.validate(10001L, request)).thenReturn(snapshots()); + when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1); + OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData); + ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing); + + service.createDraft(10001L, 2L, request, "canonical-channel-code"); + + verify(pricing).requireAvailable(10001L, 8L, " ch "); + verify(repository).insertOrder(argThat(order -> + "CH".equals(order.getChannelCode()) + && "BUY_DECLARATION".equals(order.getTaxMode()) + && "TAX_EXCLUDED".equals(order.getPricingTaxMode()))); + } + @Test void unapprovedTenantCanUpdateDraft() { OrderRepository repository = mock(OrderRepository.class); @@ -79,6 +129,26 @@ class OrderApplicationServiceStateTest { verify(repository).saveOrder(eq(10001L), argThat(saved -> saved.getStatus() == OrderStatus.DRAFT)); } + @Test + void updatingDraftValidatesCurrentPriceBookChannelBeforePersistence() { + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class); + OrderRequests.SaveRequest request = request(); + OrderEntity order = persistedOrder(OrderStatus.DRAFT); + when(repository.findOwned(10001L, 1L)).thenReturn(order); + when(masterData.validate(10001L, request)).thenReturn(snapshots()); + when(repository.saveOrder(10001L, order)).thenReturn(1); + OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData); + ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing); + + service.updateDraft(10001L, 2L, 1L, request); + + var ordered = inOrder(pricing, repository); + ordered.verify(pricing).requireAvailable(10001L, 8L, "CH"); + ordered.verify(repository).saveOrder(10001L, order); + } + @Test void platformCanEditSubmittedOrderWithoutRollingBackReviewStatus() { OrderRepository repository = mock(OrderRepository.class); @@ -102,24 +172,6 @@ class OrderApplicationServiceStateTest { && "UPDATE".equals(history.getAction()))); } - @Test - void platformAssignsTrustedCarrierSnapshotToSubmittedOrder() { - OrderRepository repository = mock(OrderRepository.class); - OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); - OrderEntity order = persistedOrder(OrderStatus.SUBMITTED); - when(repository.findOwned(10001L, 1L)).thenReturn(order); - when(repository.saveOrder(10001L, order)).thenReturn(1); - when(masterData.validateCarrier(9L)).thenReturn( - new PartnerClient.Partner(9L, 0L, "服务端船司名", "ACTIVE", "CARRIER", List.of("CARRIER"))); - - service(repository, mock(OrderEligibilityGuard.class), masterData) - .assignCarrier(10001L, 2L, 1L, new OrderRequests.CarrierRequest(9L)); - - assertThat(order.getCarrierId()).isEqualTo(9L); - assertThat(order.getCarrierName()).isEqualTo("服务端船司名"); - verify(repository).saveOrder(10001L, order); - } - @Test void approvalDoesNotRequireCarrierAssignmentAndPublishesInboundCode() { OrderRepository repository = mock(OrderRepository.class); @@ -137,26 +189,6 @@ class OrderApplicationServiceStateTest { && event.getPayloadJson().contains("\"carrierId\":null"))); } - @Test - void approvalRevalidatesCarrierAndRefreshesItsName() { - OrderRepository repository = mock(OrderRepository.class); - OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); - OrderEntity order = persistedOrder(OrderStatus.SUBMITTED); - order.setCarrierId(9L); - order.setCarrierName("旧名称"); - when(repository.findOwned(10001L, 1L)).thenReturn(order); - when(repository.saveOrder(10001L, order)).thenReturn(1); - when(masterData.validateCarrier(9L)).thenReturn( - new PartnerClient.Partner(9L, 0L, "最新船司名", "ACTIVE", "CARRIER", List.of("CARRIER"))); - - service(repository, mock(OrderEligibilityGuard.class), masterData) - .transit(10001L, 2L, "u", 1L, OrderStatus.APPROVED, null); - - assertThat(order.getStatus()).isEqualTo(OrderStatus.APPROVED); - assertThat(order.getCarrierName()).isEqualTo("最新船司名"); - verify(masterData).validateCarrier(9L); - } - @Test void submittingDraftGeneratesAndPersistsInboundCode() { OrderRepository repository = mock(OrderRepository.class); @@ -176,6 +208,57 @@ class OrderApplicationServiceStateTest { verify(repository).saveOrder(20002L, order); } + @Test + void submittingLegacyDraftRequiresPricingTaxMode() { + OrderRepository repository = mock(OrderRepository.class); + OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS"); + order.setPricingTaxMode(null); + when(repository.findOwned(20002L, 1L)).thenReturn(order); + + assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), + mock(OrderMasterDataGuard.class)).transit( + 20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null)) + .isInstanceOf(BizException.class) + .hasMessage("请选择包税或不包税报价"); + + verify(repository, never()).saveOrder(anyLong(), any()); + } + + @Test + void priceBookVersionSwitchBlocksSubmissionWhenDraftChannelWasRemoved() { + OrderRepository repository = mock(OrderRepository.class); + OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class); + OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS"); + when(repository.findOwned(20002L, 1L)).thenReturn(order); + when(repository.findCargo(20002L, 1L)).thenReturn(List.of(new OrderCargoEntity())); + when(repository.findParties(20002L, 1L)).thenReturn(List.of()); + doThrow(new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择")) + .when(pricing).requireAvailable(20002L, 8L, "CH"); + OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), + mock(OrderMasterDataGuard.class)); + ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing); + + assertThatThrownBy(() -> service.transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null)) + .isInstanceOf(BizException.class) + .hasMessage("所选渠道已下线,请重新选择"); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.DRAFT); + verify(repository, never()).saveOrder(anyLong(), any()); + } + + @Test + void inboundCommandKeepsDeclarationAndPricingTaxModesIndependent() { + OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS"); + order.setOrderNo("FL1"); + order.setTaxMode("BUY_DECLARATION"); + order.setPricingTaxMode("TAX_EXCLUDED"); + + var command = OrderApplicationService.inboundCommand(order); + + assertThat(command.taxMode()).isEqualTo("BUY_DECLARATION"); + assertThat(command.pricingTaxMode()).isEqualTo("TAX_EXCLUDED"); + } + @Test void merchantDraftEditorStillCannotEditSubmittedOrder() { OrderRepository repository = mock(OrderRepository.class); @@ -315,6 +398,8 @@ class OrderApplicationServiceStateTest { ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "eligibilityGuard", eligibility); ReflectionTestUtils.setField(service, "masterDataGuard", masterData); + ReflectionTestUtils.setField(service, "pricingChannelGuard", mock(OrderPricingChannelGuard.class)); + ReflectionTestUtils.setField(service, "productImageGuard", mock(ProductImageGuard.class)); ReflectionTestUtils.setField(service, "json", new ObjectMapper()); return service; } @@ -325,11 +410,12 @@ class OrderApplicationServiceStateTest { var cargo = new OrderRequests.Cargo( "货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE, null, null, null, null, null, false, null, null, null, null, - null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null); return new OrderRequests.SaveRequest( 7L, "客户", "SEA", null, "US", null, "目的仓", null, null, party, party, List.of(cargo), "PICKUP", 8L, "收货仓", "CH", "渠道", 9L, "船司", - "BUY_DECLARATION", "LCL", false, "OVERSEAS", null, null, null); + "BUY_DECLARATION", "TAX_INCLUDED", "LCL", false, "OVERSEAS", null, null, null); } private OrderRequests.SaveRequest request(Long customerId, String pickupMethod, @@ -338,11 +424,12 @@ class OrderApplicationServiceStateTest { var cargo = new OrderRequests.Cargo( "货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE, null, null, null, null, null, false, null, null, null, null, - null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null); return new OrderRequests.SaveRequest( customerId, "当前企业", "SEA", null, "US", null, "目的仓", null, null, sender, receiver, List.of(cargo), pickupMethod, 8L, "收货仓", "CH", "渠道", 9L, "船司", - "BUY_DECLARATION", "LCL", false, destinationType, null, null, null); + "BUY_DECLARATION", "TAX_INCLUDED", "LCL", false, destinationType, null, null, null); } private OrderMasterDataGuard.Snapshots snapshots() { @@ -368,6 +455,7 @@ class OrderApplicationServiceStateTest { order.setChannelCode("CH"); order.setChannelName("渠道"); order.setTaxMode("BUY_DECLARATION"); + order.setPricingTaxMode("TAX_INCLUDED"); order.setCustomsInspection(false); order.setDestinationType(destinationType); return order; diff --git a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java index 8f53b4125..f56372c80 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java @@ -39,6 +39,8 @@ class OrderIdempotencyServiceTest { @MockBean private OrderRepository contextRepository; @MockBean private OrderEligibilityGuard contextEligibility; @MockBean private OrderMasterDataGuard contextMasterData; + @MockBean private OrderPricingChannelGuard contextPricingChannelGuard; + @MockBean private ProductImageGuard contextProductImageGuard; @Resource private OrderApplicationService springService; @Configuration(proxyBeanMethods = false) @@ -151,6 +153,10 @@ class OrderIdempotencyServiceTest { .contains("platform:order:edit") .contains("platform:order:submit") .contains("name: Idempotency-Key") + .contains("pricingTaxMode") + .contains("PricingTaxMode") + .contains("FirstLegOrderDraft") + .contains("FirstLegOrderSummary") .contains("required: true") .contains("仅响应体 code 为字符串 \"200\" 时表示成功") .doesNotContain("'409':") @@ -158,7 +164,7 @@ class OrderIdempotencyServiceTest { } @Test - void newAndExistingDatabaseScriptsContainActualOrderWriteFields() throws Exception { + void baselineContainsCurrentOrderWriteFieldsWithoutAddingPreLaunchPricingMigration() throws Exception { String baseline = new ClassPathResource("db/order.sql").getContentAsString(StandardCharsets.UTF_8); String migration = new ClassPathResource("db/phase1-order-extension.sql") .getContentAsString(StandardCharsets.UTF_8); @@ -169,6 +175,8 @@ class OrderIdempotencyServiceTest { assertThat(baseline).contains(column); assertThat(migration).contains(column); } + assertThat(baseline).contains("pricing_tax_mode"); + assertThat(migration).doesNotContain("phase1_add_column('ord_order', 'pricing_tax_mode'"); } private OrderApplicationService service(OrderRepository repository) { diff --git a/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java index 0aa72e870..332e3534a 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java @@ -6,6 +6,7 @@ import com.baiyue.order.entity.OutboxEventEntity; import com.baiyue.order.repository.OrderRepository; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; @@ -28,7 +29,13 @@ class OrderInboundOutboxPublisherTest { fixture.publisher.publishApprovedOrders(); - verify(fixture.client).create(eq("internal-token"), any()); + ArgumentCaptor command = + ArgumentCaptor.forClass(FirstlegInboundClient.OrderApprovedInboundCommand.class); + verify(fixture.client).create(eq("internal-token"), command.capture()); + org.assertj.core.api.Assertions.assertThat(command.getValue().inboundCode()).isEqualTo("RK-21"); + org.assertj.core.api.Assertions.assertThat(command.getValue().warehouseId()).isEqualTo(31L); + org.assertj.core.api.Assertions.assertThat(command.getValue().taxMode()).isEqualTo("BUY_DECLARATION"); + org.assertj.core.api.Assertions.assertThat(command.getValue().pricingTaxMode()).isEqualTo("TAX_EXCLUDED"); verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class)); verify(fixture.repository, never()).markOutboxRetry(anyLong(), anyLong(), any()); verify(fixture.repository, never()).markOutboxFailed(any(), anyLong()); @@ -93,6 +100,21 @@ class OrderInboundOutboxPublisherTest { OrderRepository repository = mock(OrderRepository.class); FirstlegInboundClient client = mock(FirstlegInboundClient.class); when(repository.pendingInboundEvents(50)).thenReturn(List.of(event)); + if (event.getTenantId() != null && event.getAggregateId() != null) { + var order = new com.baiyue.order.entity.OrderEntity(); + order.setId(event.getAggregateId()); + order.setTenantId(event.getTenantId()); + order.setOrderNo("FL-" + event.getAggregateId()); + order.setInboundCode("RK-" + event.getAggregateId()); + order.setCustomerName("测试客户"); + order.setReceivingWarehouseId(31L); + order.setReceivingWarehouseName("测试仓"); + order.setDestinationType("OVERSEAS"); + order.setTransportMode("SEA"); + order.setTaxMode("BUY_DECLARATION"); + order.setPricingTaxMode("TAX_EXCLUDED"); + when(repository.findOwned(event.getTenantId(), event.getAggregateId())).thenReturn(order); + } OrderInboundOutboxPublisher publisher = new OrderInboundOutboxPublisher(); ReflectionTestUtils.setField(publisher, "repository", repository); ReflectionTestUtils.setField(publisher, "client", client); diff --git a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java index 48e0a44c4..0abaf9e5b 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java @@ -253,8 +253,9 @@ class OrderMasterDataGuardTest { private OrderRequests.SaveRequest request(Long partner,Long warehouse,String destinationType,Long destinationWarehouseId) { var party=new OrderRequests.Party("公司","联系人","13800138000",null,"CN",null,"深圳",null,"地址",null); var cargo=new OrderRequests.Cargo("货物",null,"CARTON",1,BigDecimal.ONE,BigDecimal.ONE, - null,null,null,null,null,false,null,null,null,null,null,null,null,null,null,null,null,null,null); - return new OrderRequests.SaveRequest(partner,"不可信客户名","SEA",null,"CN",destinationWarehouseId,"私人地址",null,null,party,party,List.of(cargo),"PICKUP",warehouse,"不可信收货仓","CH","渠道",9L,"船司","BUY_DECLARATION","LCL",false,destinationType,null,null,null); + null,null,null,null,null,false,null,null,null,null,null,null,null,null,null,null,null,null,null, + null,null,null,null,null,null); + return new OrderRequests.SaveRequest(partner,"不可信客户名","SEA",null,"CN",destinationWarehouseId,"私人地址",null,null,party,party,List.of(cargo),"PICKUP",warehouse,"不可信收货仓","CH","渠道",9L,"船司","BUY_DECLARATION",null,"LCL",false,destinationType,null,null,null); } private WarehouseClient.Detail warehouse(long id,String type,boolean receiving) { var value=new WarehouseClient.Warehouse(id,PUBLIC_TENANT_ID,"WH-"+id,"仓库"+id,type,"CN","ACTIVE"); diff --git a/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java new file mode 100644 index 000000000..a68617c16 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java @@ -0,0 +1,85 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.client.FirstlegPricingClient; +import feign.FeignException; +import feign.Request; +import feign.Response; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("订单价格渠道校验") +@SpringJUnitConfig(OrderPricingChannelGuard.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=token") +class OrderPricingChannelGuardTest { + @MockBean private FirstlegPricingClient client; + @Resource private OrderPricingChannelGuard guard; + + @Test + void forwardsCustomerOriginAndChannelToFirstleg() { + var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK"); + when(client.validateChannel("token", request)).thenReturn(ApiResponse.success(null)); + + guard.requireAvailable(20002L, 8L, "MATSON_TRUCK"); + + verify(client).validateChannel("token", request); + } + + @Test + void removedOrForgedChannelKeepsFirstlegBusinessError() { + var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "FAKE"); + when(client.validateChannel("token", request)).thenReturn( + ApiResponse.failure(ResultCode.CONFLICT, "所选渠道已下线,请重新选择", null, null)); + + assertThatThrownBy(() -> guard.requireAvailable(20002L, 8L, "FAKE")) + .isInstanceOf(BizException.class) + .hasMessage("所选渠道已下线,请重新选择") + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + } + + @Test + void nullResponseMapsToServiceUnavailable() { + var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK"); + when(client.validateChannel("token", request)).thenReturn(null); + + assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK")); + } + + @Test + void downstreamHttpFailureMapsToServiceUnavailable() { + var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK"); + when(client.validateChannel("token", request)).thenThrow(feign(403)); + + assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK")); + } + + private void assertUnavailable(org.assertj.core.api.ThrowableAssert.ThrowingCallable invocation) { + assertThatThrownBy(invocation) + .isInstanceOf(BizException.class) + .hasMessage("头程价格渠道校验服务暂时不可用") + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); + } + + private FeignException feign(int status) { + Request request = Request.create(Request.HttpMethod.POST, "http://firstleg-service/internal/test", + Map.of(), null, StandardCharsets.UTF_8, null); + Response response = Response.builder().status(status).reason("test").request(request) + .headers(Map.of()).build(); + return FeignException.errorStatus("pricingChannel", response); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java b/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java index d11f28798..10e802c31 100644 --- a/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java @@ -18,6 +18,8 @@ class PlatformOrderPageServiceTest { @MockBean private OrderRepository repository; @MockBean private OrderEligibilityGuard eligibilityGuard; @MockBean private OrderMasterDataGuard masterDataGuard; + @MockBean private OrderPricingChannelGuard pricingChannelGuard; + @MockBean private ProductImageGuard productImageGuard; @Resource private OrderApplicationService service; @Configuration(proxyBeanMethods = false) static class TestConfig { diff --git a/order/src/test/java/com/baiyue/order/service/ProductImageGuardTest.java b/order/src/test/java/com/baiyue/order/service/ProductImageGuardTest.java new file mode 100644 index 000000000..ab6cbf4e6 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/ProductImageGuardTest.java @@ -0,0 +1,75 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.client.ProductImageClient; +import com.baiyue.order.dto.OrderRequests; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(ProductImageGuard.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=token") +class ProductImageGuardTest { + @MockBean private ProductImageClient client; + @Resource private ProductImageGuard guard; + + @Test + void validatesEachDistinctImageAgainstTheCurrentCustomer() { + var metadata = new ProductImageClient.FileMetadata(31L, "product.png", "image/png", 100L); + when(client.validate("token", 10001L, 31L)).thenReturn(ApiResponse.success(metadata)); + + guard.requireOwned(10001L, List.of(cargo(31L), cargo(31L))); + + verify(client, times(1)).validate("token", 10001L, 31L); + } + + @Test + void preservesCrossCustomerOrMissingImageRejection() { + when(client.validate("token", 10001L, 31L)).thenReturn( + ApiResponse.failure(ResultCode.NOT_FOUND, "产品图片不存在", null, null)); + + assertThatThrownBy(() -> guard.requireOwned(10001L, List.of(cargo(31L)))) + .isInstanceOf(BizException.class).hasMessage("产品图片不存在"); + } + + @Test + void mapsUnavailableOrMalformedResponsesToServiceUnavailable() { + when(client.validate("token", 10001L, 31L)).thenReturn(null); + assertUnavailable(() -> guard.requireOwned(10001L, List.of(cargo(31L)))); + + when(client.validate("token", 10001L, 31L)).thenReturn( + ApiResponse.success(new ProductImageClient.FileMetadata(32L, "wrong.png", "image/png", 10L))); + assertUnavailable(() -> guard.requireOwned(10001L, List.of(cargo(31L)))); + } + + @Test + void rejectsInvalidLogicalIdBeforeCallingFileService() { + assertThatThrownBy(() -> guard.requireOwned(10001L, List.of(cargo(0L)))) + .isInstanceOf(BizException.class).hasMessageContaining("文件ID无效"); + } + + private void assertUnavailable(org.assertj.core.api.ThrowableAssert.ThrowingCallable invocation) { + assertThatThrownBy(invocation).isInstanceOf(BizException.class) + .hasMessage("产品图片校验服务暂时不可用") + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); + } + + private static OrderRequests.Cargo cargo(Long fileId) { + return new OrderRequests.Cargo("货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE, + null, null, null, null, null, false, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, fileId); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java b/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java index 6de6404b8..e8684f703 100644 --- a/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java @@ -21,6 +21,8 @@ class TenantOrderCountServiceTest { @MockBean private OrderRepository repository; @MockBean private OrderEligibilityGuard eligibilityGuard; @MockBean private OrderMasterDataGuard masterDataGuard; + @MockBean private OrderPricingChannelGuard pricingChannelGuard; + @MockBean private ProductImageGuard productImageGuard; @Resource private OrderApplicationService service; @Configuration(proxyBeanMethods = false) static class TestConfig { diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index b41eb2d32..54ceae900 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -6,10 +6,12 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.order.client.FirstlegUnifiedOrderClient; import com.baiyue.order.dto.UnifiedOrderQuery; import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderInternalNoteEntity; import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.repository.OrderRepository; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.mockito.ArgumentCaptor; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; @@ -34,15 +36,26 @@ class UnifiedOrderQueryServiceTest { @MockBean private FirstlegUnifiedOrderClient firstleg; @Resource private UnifiedOrderQueryService service; + @BeforeEach + void stubListEnrichmentQueries() { + when(orders.findNotesByOrderIds(any(), any())).thenReturn(List.of()); + when(orders.findByIds(any(), any())).thenReturn(List.of()); + } + @Test void pendingReviewIsPagedFromOrderAndMappedToApproveAction() { UnifiedOrderQuery query = query("PENDING_REVIEW"); OrderEntity order = order(OrderStatus.SUBMITTED); order.setInboundCode("RK-77"); + order.setCustomerRemark("客户要求提货前联系"); + OrderInternalNoteEntity note = new OrderInternalNoteEntity(); + note.setOrderId(77L); + note.setContent("客服已确认提货时间"); when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED)))) .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); when(firstleg.summaries(eq("internal-token"), any())) .thenReturn(ApiResponse.success(List.of())); + when(orders.findNotesByOrderIds(10001L, List.of(77L))).thenReturn(List.of(note)); var result = service.page(10001L, query); @@ -51,6 +64,8 @@ class UnifiedOrderQueryServiceTest { assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER"); assertThat(row.inboundCode()).isEqualTo("RK-77"); assertThat(row.inboundId()).isNull(); + assertThat(row.customerRemark()).isEqualTo("客户要求提货前联系"); + assertThat(row.latestInternalNote()).isEqualTo("客服已确认提货时间"); }); verify(firstleg, never()).summaries(any(), any()); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java index d6b4ccfdd..4c16d9d87 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java @@ -15,6 +15,8 @@ import java.security.MessageDigest; @RestController @RequestMapping("/internal/warehouses") public class InternalWarehouseController { + private static final long PLATFORM_TENANT_ID = 0L; + @Resource private WarehouseApplicationService service; @@ -30,8 +32,17 @@ public class InternalWarehouseController { public ApiResponse active( @RequestHeader(value="X-Internal-Service-Token", required=false) String token, @PathVariable long tenantId, @PathVariable long id) { - if (token == null || !MessageDigest.isEqual(expected, token.getBytes(StandardCharsets.UTF_8))) + verifyToken(token); + if (tenantId != PLATFORM_TENANT_ID) { + throw new BizException(ResultCode.BAD_REQUEST, "平台公共仓库作用域必须为0"); + } + return ApiResponse.success(service.activeDetail(PLATFORM_TENANT_ID, id)); + } + + private void verifyToken(String token) { + byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(expected, supplied)) { throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); - return ApiResponse.success(service.activeDetail(tenantId, id)); + } } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java new file mode 100644 index 000000000..aa3ff07a4 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java @@ -0,0 +1,74 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.service.WarehouseApplicationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +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.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class InternalWarehouseControllerTest { + private static final String INTERNAL_TOKEN = "test-internal-service-token"; + + private final WarehouseApplicationService service = mock(WarehouseApplicationService.class); + private final InternalWarehouseController controller = new InternalWarehouseController(); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(controller, "service", service); + ReflectionTestUtils.setField(controller, "internalToken", INTERNAL_TOKEN); + controller.initializeToken(); + } + + @Test + void returnsOnlyAnActivePlatformWarehouseToInternalCallers() { + WarehouseEntity warehouse = new WarehouseEntity(); + warehouse.setId(200001L); + warehouse.setTenantId(0L); + warehouse.setWarehouseCode("CN-DONGGUAN-01"); + warehouse.setWarehouseName("东莞仓"); + warehouse.setWarehouseType("DOMESTIC"); + warehouse.setStatus("ACTIVE"); + when(service.activeDetail(0L, 200001L)) + .thenReturn(new WarehouseDetailResponse(warehouse, List.of(), List.of())); + + var response = controller.active(INTERNAL_TOKEN, 0L, 200001L); + + assertThat(response.data().warehouse().getTenantId()).isZero(); + assertThat(response.data().warehouse().getWarehouseCode()).isEqualTo("CN-DONGGUAN-01"); + assertThat(response.data().warehouse().getWarehouseType()).isEqualTo("DOMESTIC"); + assertThat(response.data().warehouse().getStatus()).isEqualTo("ACTIVE"); + verify(service).activeDetail(0L, 200001L); + } + + @Test + void rejectsCustomerScopedWarehouseLookups() { + assertThatThrownBy(() -> controller.active(INTERNAL_TOKEN, 10001L, 200001L)) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.BAD_REQUEST); + + verifyNoInteractions(service); + } + + @Test + void rejectsAnInvalidInternalTokenBeforeQueryingWarehouseData() { + assertThatThrownBy(() -> controller.active("invalid-token", 0L, 200001L)) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.FORBIDDEN); + + verifyNoInteractions(service); + } +}