Merge branch 'test' into test-02huogui

# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java
#	firstleg/src/main/resources/application.yml
#	firstleg/src/main/resources/openapi/fba-pricing-v1.yaml
#	firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java
#	order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java
#	order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java
This commit is contained in:
2026-08-07 19:49:09 +08:00
192 changed files with 10729 additions and 391 deletions
@@ -40,6 +40,11 @@ public class InternalAccountController {
@Valid @RequestBody CreateAccountRequest request) {
verify(token); return ApiResponse.success(service.create(request));
}
@GetMapping("/availability")
public ApiResponse<Boolean> 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<AccountSummary> summary(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id) {
@@ -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) {
@@ -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);
+9
View File
@@ -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:}
+9
View File
@@ -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:}
+7
View File
@@ -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 和客户隔离键,不表示上级租户;费用项目和入库费用明细沿用该物理字段名。
+452 -37
View File
@@ -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;
@@ -17,7 +17,6 @@ public interface WeightOcrClient {
ApiResponse<Result> 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) {}
}
@@ -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<PriceBookFileService.FileMetadata> 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<PriceBookFileService.FileMetadata> 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<byte[]> 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");
}
}
}
@@ -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<ProductImageService.UploadResult> 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, "内部服务凭证无效");
}
}
}
@@ -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<ProductImageService.UploadResult> 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<byte[]> 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();
}
}
@@ -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)
@@ -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<FileObjectEntity>()
.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) {}
}
@@ -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<String> ACCEPTED_CONTENT_TYPES = Set.of(
XLSX_CONTENT_TYPE, "application/octet-stream", "application/zip");
private static final Set<String> 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<String> 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) {}
}
@@ -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<FileObjectEntity>()
.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) {}
}
@@ -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<String> 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<ImageReader> 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) {}
}
+2
View File
@@ -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
@@ -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);
@@ -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);
}
}
@@ -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());
}
}
@@ -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<FileObjectEntity> 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;
}
}
@@ -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;
}
}
@@ -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<String, byte[]> entries = requiredEntries();
entries.put("xl/tables/table1.xml", "<table ref=\"5:31\">".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<String, byte[]> requiredEntries() {
Map<String, byte[]> entries = new LinkedHashMap<>();
entries.put("[Content_Types].xml", "<Types/>".getBytes());
entries.put("xl/workbook.xml", "<workbook/>".getBytes());
entries.put("xl/_rels/workbook.xml.rels", "<Relationships/>".getBytes());
return entries;
}
private static byte[] zip(Map<String, byte[]> entries) throws IOException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(bytes)) {
for (Map.Entry<String, byte[]> entry : entries.entrySet()) {
zip.putNextEntry(new ZipEntry(entry.getKey()));
zip.write(entry.getValue());
zip.closeEntry();
}
}
return bytes.toByteArray();
}
}
@@ -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();
}
}
+1
View File
@@ -18,6 +18,7 @@
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency>
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-loadbalancer</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-stream-rocketmq</artifactId></dependency>
<dependency><groupId>org.apache.poi</groupId><artifactId>poi-ooxml</artifactId><version>5.3.0</version></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
@@ -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<List<CargoLabelBinding>> 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) {
}
}
@@ -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<FileMetadata> 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<FileMetadata> 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<byte[]> 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) {}
}
@@ -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<Detail> active(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable("tenantId") long tenantId,
@PathVariable("id") long id);
record Detail(Warehouse warehouse, List<Address> 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) {}
}
@@ -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<byte[]> 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<Label> labels) {
}
record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {
}
record Label(String topCode, String warehouseText, String cartonText,
String routeText, String qrContent) {
}
}
@@ -0,0 +1,61 @@
package com.baiyue.firstleg.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.firstleg.dto.CustomerDimensionConfigDtos.InitializeRequest;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeResponse;
import com.baiyue.firstleg.service.CustomerDimensionConfigService;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value;
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;
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/firstleg/v1/customer-dimension-configs")
public class InternalCustomerDimensionConfigController {
@Resource
private CustomerDimensionConfigService service;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
private byte[] expectedToken;
@PostConstruct
void initializeToken() {
expectedToken = internalToken.getBytes(StandardCharsets.UTF_8);
}
@PostMapping("/defaults")
public ApiResponse<InitializeResponse> initializeDefault(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody InitializeRequest request) {
requireToken(token);
return ApiResponse.success(service.initializeDefault(
request.tenantId(), request.operatorId(), request.initializationKey()));
}
@DeleteMapping("/defaults")
public ApiResponse<Void> compensateDefault(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestParam long tenantId, @RequestParam String initializationKey) {
requireToken(token);
service.compensateDefault(tenantId, initializationKey);
return ApiResponse.success(null);
}
private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
}
}
@@ -0,0 +1,47 @@
package com.baiyue.firstleg.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.firstleg.dto.PriceBookChannelValidation;
import com.baiyue.firstleg.service.MerchantPriceBookService;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@RestController
@RequestMapping("/internal/firstleg/v1/pricing")
public class InternalPriceBookController {
@Resource private MerchantPriceBookService service;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
private byte[] expectedToken;
@PostConstruct
void initializeToken() {
expectedToken = internalToken.getBytes(StandardCharsets.UTF_8);
}
@PostMapping("/channels/validate")
public ApiResponse<Void> validateChannel(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody PriceBookChannelValidation.Request request) {
requireToken(token);
service.requireAvailableChannel(request.tenantId(), request.originWarehouseId(), request.channelCode());
return ApiResponse.success(null);
}
private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
}
}
@@ -0,0 +1,48 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.PriceBookDtos.CurrentChannel;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateResponse;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.MerchantPriceBookService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "客户端船期渠道与参考估价")
@RestController
@RequestMapping("/api/merchant/v1/first-leg/pricing")
public class MerchantPriceBookController {
@Resource private MerchantPriceBookService service;
@GetMapping("/channels")
@PreAuthorize("hasAnyAuthority('merchant:order:create','merchant:order:edit','merchant:order:view')")
@Operation(summary = "查询当前起运仓可选渠道")
public ApiResponse<List<CurrentChannel>> channels(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long originWarehouseId) {
return ApiResponse.success(service.channels(FirstlegScopeGuard.merchant(user), originWarehouseId));
}
@PostMapping("/estimate")
@PreAuthorize("hasAnyAuthority('merchant:order:create','merchant:order:edit','merchant:order:view')")
@Operation(summary = "计算头程基础参考费用")
public ApiResponse<EstimateResponse> estimate(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody EstimateRequest request) {
return ApiResponse.success(service.estimate(FirstlegScopeGuard.merchant(user), request));
}
}
@@ -0,0 +1,50 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.Response;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest;
import com.baiyue.firstleg.service.CustomerDimensionConfigService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "客户尺寸计算配置")
@RestController
@RequestMapping("/api/platform/v1/first-leg/customer-dimension-config")
public class PlatformCustomerDimensionConfigController {
@Resource
private CustomerDimensionConfigService service;
@GetMapping
@PreAuthorize("hasAuthority('firstleg:dimension-config:view')")
@Operation(summary = "查询客户尺寸计算配置")
public ApiResponse<Response> get(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId) {
return ApiResponse.success(service.get(FirstlegScopeGuard.platform(user, tenantId)));
}
@PutMapping
@PreAuthorize("hasAuthority('firstleg:dimension-config:edit')")
@BusinessAudit(event = "CUSTOMER_DIMENSION_CONFIG_UPDATE",
resourceType = "DIMENSION_CONFIG", resourceId = "#tenantId",
description = "平台编辑客户尺寸计算配置")
@Operation(summary = "编辑客户尺寸计算配置")
public ApiResponse<Response> update(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@Valid @RequestBody UpdateRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.update(scope, user.userId(), request));
}
}
@@ -9,6 +9,9 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.dto.CartonMeasurementResult;
import com.baiyue.firstleg.dto.CartonMeasurementCommand;
import com.baiyue.firstleg.service.CartonMeasurementService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.InboundMeasurementService;
import com.baiyue.firstleg.service.MeasurementSubmissionService;
@@ -21,10 +24,13 @@ 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.PutMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile;
/** 平台后台入库测量录入、提交和 OCR 重量结果维护接口。 */
@Tag(name = "平台入库测量", description = "维护测量草稿、箱规明细和可追溯 OCR 重量结果。")
@@ -33,6 +39,7 @@ import org.springframework.web.bind.annotation.RestController;
public class PlatformInboundMeasurementController {
@Resource private InboundMeasurementService service;
@Resource private MeasurementSubmissionService submission;
@Resource private CartonMeasurementService cartonMeasurements;
@Operation(summary = "查询入库测量详情", description = "返回原单、实测箱规、审核快照和 OCR 重量结果。")
@GetMapping("/measurement")
@@ -74,6 +81,44 @@ public class PlatformInboundMeasurementController {
return ApiResponse.success(submission.submit(tenantId, inboundId, user.userId(), request));
}
@BusinessAudit(event = "INBOUND_CARTON_MEASUREMENT_COUNT_CONFIRM",
resourceType = "INBOUND_MEASUREMENT_ITEM", resourceId = "#itemId",
description = "确认单箱测量记录代表箱数")
@Operation(summary = "确认单箱测量记录代表箱数",
description = "只修改人工确认箱数并由服务端重算当前测量批次汇总。")
@PutMapping("/measurement/items/{itemId}/confirmed-package-count")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')")
public ApiResponse<CartonMeasurementResult> updateConfirmedPackageCount(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@PathVariable long itemId,
@Valid @RequestBody InboundMeasurementRequests.ConfirmedPackageCountRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(cartonMeasurements.updateConfirmedPackageCount(
tenantId, inboundId, itemId, request.measurementVersion(), request.confirmedPackageCount()));
}
@BusinessAudit(event = "INBOUND_CARTON_SCAN", resourceType = "INBOUND",
resourceId = "#inboundId", description = "平台模拟设备单箱扫描")
@Operation(summary = "平台模拟单箱设备扫描",
description = "联调入口;data 中传箱号和设备测量值,image 传当前箱图片,复用正式单箱覆盖规则。")
@PostMapping(value = "/measurement/carton-scans", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')")
public ApiResponse<CartonMeasurementResult> simulateCartonScan(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@Valid @RequestPart("data") InboundMeasurementRequests.CartonScanRequest request,
@RequestPart("image") MultipartFile image) {
requirePlatformTenant(user, tenantId);
CartonMeasurementCommand command = new CartonMeasurementCommand(
request.boxCode(), request.length(), request.width(), request.height(), request.weight(),
request.machineNo(), request.measuredAt(), image);
return ApiResponse.success(cartonMeasurements.upsertForInbound(
tenantId, user.userId(), inboundId, command));
}
@BusinessAudit(event = "INBOUND_WEIGHT_OCR_RECORD", resourceType = "INBOUND",
resourceId = "#inboundId", description = "登记称重图片 OCR 原值")
@Operation(summary = "登记 OCR 重量结果", description = "按图片文件 ID 保存不可覆盖的 OCR 原值。")
@@ -106,9 +151,11 @@ public class PlatformInboundMeasurementController {
@BusinessAudit(event = "INBOUND_WEIGHT_OCR_CONFIRM", resourceType = "INBOUND_WEIGHT_RESULT",
resourceId = "#resultId", description = "确认 OCR 重量并累加实际总毛重")
@Operation(summary = "确认 OCR 重量", description = "统一换算为 KG 并重算所有已确认图片的总重量。")
@Operation(summary = "确认 OCR 重量",
description = "可在同一事务中携带人工修改值并确认;统一换算为 KG 后重算所有已确认图片的总重量。")
@PostMapping("/weight-results/{resultId}/confirm")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:confirm')")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:confirm')"
+ " and (!#request.hasAdjustment() or hasAuthority('platform:firstleg:inbound:weight:edit'))")
public ApiResponse<InboundMeasurementResponses.WeightResult> confirmWeightResult(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@@ -11,6 +11,7 @@ import com.baiyue.firstleg.dto.InboundOrderDetail;
import com.baiyue.firstleg.dto.InboundOrderQuery;
import com.baiyue.firstleg.dto.InboundOrderSummary;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.InboundCartonLabelPrintService;
import com.baiyue.firstleg.service.InboundOrderQueryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -22,9 +23,14 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
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.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.ResponseEntity;
import java.util.List;
/** 平台端入库单列表和详情只读接口。 */
@Tag(name = "平台入库单", description = "二期入库单列表、页签、筛选和只读聚合详情")
@@ -33,6 +39,7 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/platform/inbounds")
public class PlatformInboundOrderController {
@Resource private InboundOrderQueryService service;
@Resource private InboundCartonLabelPrintService cartonLabelPrintService;
@Operation(summary = "分页查询入库单",
description = "默认分页查询全部企业入库单;可按目标租户和二期状态页签筛选入库单快照。")
@@ -61,6 +68,23 @@ public class PlatformInboundOrderController {
return ApiResponse.success(service.detail(tenantId, inboundId));
}
@Operation(summary = "生成入仓箱唛 PDF",
description = "首次调用按订单货物明细冻结整单箱号;重复打印及指定箱下标补打复用原箱号映射。")
@PostMapping("/{inboundId}/carton-label-pdf")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:view')")
public ResponseEntity<byte[]> printCartonLabels(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@RequestParam(required = false) List<@Min(1) Integer> cartonIndex,
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
requirePlatformTenant(user, tenantId);
if (inboundId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "入库单ID必须大于零");
}
return cartonLabelPrintService.generate(tenantId, inboundId, cartonIndex, requestId);
}
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
FirstlegScopeGuard.platform(user, tenantId);
}
@@ -0,0 +1,84 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.domain.PriceBookStatus;
import com.baiyue.firstleg.dto.PriceBookConfirmationRequest;
import com.baiyue.firstleg.dto.PriceBookDtos;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.PlatformPriceBookService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
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.validation.annotation.Validated;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@Tag(name = "平台船期价格表")
@Validated
@RestController
@RequestMapping("/api/platform/v1/fba-pricing/carrier-rates/price-books")
public class PlatformPriceBookController {
@Resource private PlatformPriceBookService service;
@GetMapping
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
@Operation(summary = "查询船期价格表版本")
public ApiResponse<java.util.List<PriceBookDtos.PriceBookVersionSummary>> list(
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
@RequestParam(required = false) PriceBookStatus status) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.list(status));
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:edit')")
@BusinessAudit(event = "FBA_PRICE_BOOK_UPLOAD", resourceType = "PRICE_BOOK_VERSION", description = "上传船期价格表并等待确认")
public ApiResponse<PriceBookDtos.PriceBookVersionDetail> upload(
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
@RequestPart("file") MultipartFile file) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.upload(user.userId(), file));
}
@GetMapping("/{versionId}")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
public ApiResponse<PriceBookDtos.PriceBookVersionDetail> detail(
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
@PathVariable long versionId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.detail(versionId));
}
@PostMapping("/{versionId}/publish")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:edit')")
@BusinessAudit(event = "FBA_PRICE_BOOK_CONFIRM", resourceType = "PRICE_BOOK_VERSION", resourceId = "#versionId", description = "确认仓库映射并生效船期价格表")
public ApiResponse<PriceBookDtos.PriceBookVersionDetail> publish(
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
@PathVariable long versionId,
@Valid @RequestBody PriceBookConfirmationRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.publish(versionId, user.userId(), request));
}
@GetMapping("/{versionId}/source")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
public ResponseEntity<byte[]> source(
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
@PathVariable long versionId) {
FirstlegScopeGuard.platform(user);
return service.source(versionId, user.userId());
}
}
@@ -0,0 +1,43 @@
package com.baiyue.firstleg.domain;
import java.util.List;
import java.util.Set;
/** 首版固定渠道目录;上传名称只能映射到这些稳定编码。 */
public final class PriceBookChannelCatalog {
private PriceBookChannelCatalog() {}
public static final List<ChannelDefinition> CHANNELS = List.of(
fba("MATSON_CLX_TRUCK", "美森正班海卡", "美森正班海卡"),
fba("MATSON_MAX_TRUCK", "美森加班海卡", "美森加班海卡"),
fba("EXX_TRUCK", "EXX海卡", "EXX海卡"),
fba("HEDE_TRUCK", "合德海卡", "合德海卡"),
fba("EXX_RAIL_NY", "纽约EXX海铁", "纽约EXX海铁"),
fba("OA_LA_TRUCK", "洛杉矶普船海卡", "洛杉矶普船海卡"),
fba("OA_LA_FULL_TRUCK", "洛杉矶普船全卡", "洛杉矶普船全卡"),
fba("OA_CHICAGO_RAIL", "芝加哥普船海铁", "芝加哥普船海铁"),
fba("OA_HOUSTON_TRUCK", "休斯顿普船海卡", "休斯顿普船海卡"),
fba("OA_NEW_YORK_TRUCK", "纽约普船海卡", "纽约普船海卡"),
fba("OA_SAVANNAH_TRUCK", "萨凡纳普船海卡", "萨凡纳普船海卡"),
parcel("MATSON_CLX_PARCEL", "美森正班海派", "海派"),
parcel("MATSON_MAX_PARCEL", "美森加班海派", "海派"),
parcel("HEDE_PARCEL", "合德海派", "海派"),
parcel("OA_PARCEL", "普船海派", "海派")
);
private static ChannelDefinition fba(String code, String name, String sheet) {
return new ChannelDefinition(code, name, PriceModel.FBA_WAREHOUSE_RATE, sheet, Set.of(name));
}
private static ChannelDefinition parcel(String code, String name, String sheet) {
return new ChannelDefinition(code, name, PriceModel.REGION_WEIGHT_TIER, sheet, Set.of(name));
}
public record ChannelDefinition(
String channelCode,
String displayName,
PriceModel priceModel,
String sourceSheetName,
Set<String> aliases
) {}
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.domain;
/** 平台公共船期价格表版本状态;上传后待确认映射,确认即生效。 */
public enum PriceBookStatus {
PENDING_CONFIRMATION,
ACTIVE,
SUPERSEDED,
FAILED
}
@@ -0,0 +1,7 @@
package com.baiyue.firstleg.domain;
/** 当前首版支持的两种明确价格模型,不扩展为通用规则引擎。 */
public enum PriceModel {
FBA_WAREHOUSE_RATE,
REGION_WEIGHT_TIER
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.domain;
/** 海派首版固定美国区域;优先由目的仓邮编或版本内区划数据解析。 */
public enum PriceRegion {
WEST,
CENTRAL,
EAST
}
@@ -0,0 +1,7 @@
package com.baiyue.firstleg.domain;
/** 价格明细支持的目的地类型;PRIVATE 本期不参与自动估价。 */
public enum PricingDestinationType {
FBA,
OVERSEAS
}
@@ -0,0 +1,7 @@
package com.baiyue.firstleg.domain;
/** 报价包税维度;与订单的买单/退税 taxMode 是两个独立概念。 */
public enum PricingTaxMode {
TAX_INCLUDED,
TAX_EXCLUDED
}
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.dto;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 已完成外部协议转换后的单箱设备测量命令。 */
public record CartonMeasurementCommand(
String boxCode,
BigDecimal lengthCm,
BigDecimal widthCm,
BigDecimal heightCm,
BigDecimal unitWeightKg,
String machineNo,
LocalDateTime measuredAt,
MultipartFile image
) {
}
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 单箱当前测量结果;confirmedPackageCount 为平台人工确认的代表箱数。 */
public record CartonMeasurementResult(
Long measurementId,
Integer measurementVersion,
Long itemId,
String boxCode,
Integer confirmedPackageCount,
BigDecimal lengthCm,
BigDecimal widthCm,
BigDecimal heightCm,
BigDecimal unitWeightKg,
Long imageFileId,
String machineNo,
LocalDateTime measuredAt,
boolean created
) {
}
@@ -0,0 +1,41 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public final class CustomerDimensionConfigDtos {
private CustomerDimensionConfigDtos() { }
public record UpdateRequest(
@NotNull @DecimalMin("0.001") @DecimalMax("100.000") @Digits(integer = 3, fraction = 3)
BigDecimal edgeAdditionCm,
@Size(max = 500) String remark,
@NotNull @Min(0) Integer version
) { }
public record Response(
Long id,
Long tenantId,
BigDecimal edgeAdditionCm,
String remark,
Integer version,
LocalDateTime createdAt,
LocalDateTime updatedAt
) { }
public record InitializeRequest(
@NotNull @Min(1) Long tenantId,
@NotNull @Min(0) Long operatorId,
@NotBlank @Size(max = 128) String initializationKey
) { }
public record InitializeResponse(Long configId, boolean createdByThisInitialization) { }
}
@@ -59,12 +59,13 @@ public final class FbaPricingDtos {
public record FeeSnapshotResponse(
Long feeId, String feeNo, Integer feeVersion, String feeStatus,
Long inboundId, String inboundCode,
Long chargeRuleId, String ruleCode, Integer chargeRuleVersion,
Long carrierRateId, String rateCode, Integer carrierRateVersion,
Long customerId, String customerName, Long originWarehouseId, String originWarehouseName,
String businessType, String channelCode, Long carrierId, String carrierName,
String destinationCode, String taxMode, String chargeMode,
Long inboundId, String inboundCode,
Long chargeRuleId, String ruleCode, Integer chargeRuleVersion,
Long carrierRateId, String rateCode, Integer carrierRateVersion, Long priceBookVersionId,
Long customerId, String customerName, Long originWarehouseId, String originWarehouseName,
String businessType, String channelCode, Long carrierId, String carrierName,
String destinationType, Long destinationWarehouseId, String destinationName, String destinationCode,
String taxMode, String pricingTaxMode, String chargeMode,
BigDecimal conversionFactor, Integer roundingScale, String roundingMode,
BigDecimal actualWeightKg, BigDecimal actualVolumeCbm,
BigDecimal convertedQuantity, BigDecimal chargeableQuantity, String chargeUnit,
@@ -0,0 +1,26 @@
package com.baiyue.firstleg.dto;
import java.math.BigDecimal;
import java.util.List;
/** 稳定箱号分配、补打和测量解析契约。 */
public final class InboundCartonLabels {
private InboundCartonLabels() {
}
public record Batch(Long inboundId, Long orderId, String inboundCode, Integer totalCount,
List<Label> labels) {
}
public record Label(Long id, Long inboundDetailId, Integer cargoItemNo, String cargoName,
Integer detailPackageCount, String boxCode, Integer totalCount,
Integer cartonIndex, String boxSpecification, BigDecimal lengthCm,
BigDecimal widthCm, BigDecimal heightCm, String topCode,
String warehouseText, String cartonText, String routeText) {
}
public record Resolution(Long inboundId, Long orderId, Long inboundDetailId, String boxCode,
Integer totalCount, Integer cartonIndex, Integer cargoItemNo,
String cargoName, String boxSpecification) {
}
}
@@ -13,6 +13,7 @@ import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
@@ -57,14 +58,31 @@ public final class InboundMeasurementRequests {
) {
}
public record ConfirmedPackageCountRequest(
@NotNull @PositiveOrZero Integer measurementVersion,
@NotNull @Positive @Max(1000000) Integer confirmedPackageCount
) {
}
/** 平台联调使用;最终设备协议在外部适配层转换为相同领域命令。 */
public record CartonScanRequest(
@NotBlank @Size(max = 128) String boxCode,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal length,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal width,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal height,
@DecimalMin("0.001") @Digits(integer = 15, fraction = 3) BigDecimal weight,
@NotBlank @Size(max = 64) String machineNo,
@NotNull LocalDateTime measuredAt
) {
}
public record CreateWeightResultRequest(
@NotNull @Positive Long fileId,
@Size(max = 64) String ocrProvider,
@Size(max = 1000) String ocrRawText,
@DecimalMin("0.000") @Digits(integer = 15, fraction = 3) BigDecimal ocrWeightValue,
@Size(max = 8) String ocrWeightUnit,
@DecimalMin("0.00000") @DecimalMax("1.00000")
@Digits(integer = 1, fraction = 5) BigDecimal confidence
@DecimalMin("0.00000") @DecimalMax("1.00000") BigDecimal confidence
) {
}
@@ -77,7 +95,13 @@ public final class InboundMeasurementRequests {
}
public record ConfirmWeightResultRequest(
@NotNull @PositiveOrZero Integer version
@NotNull @PositiveOrZero Integer version,
@DecimalMin("0.001") @Digits(integer = 15, fraction = 3) BigDecimal adjustedWeightValue,
@Size(max = 8) String adjustedWeightUnit,
@Size(max = 500) String adjustmentReason
) {
public boolean hasAdjustment() {
return adjustedWeightValue != null || adjustedWeightUnit != null || adjustmentReason != null;
}
}
}
@@ -34,6 +34,7 @@ public final class InboundMeasurementResponses {
String originalDocumentNo,
BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm,
Integer originalPackageCount,
Integer actualPackageCount,
BigDecimal actualGrossWeightKg,
BigDecimal actualVolumeCbm,
@@ -64,7 +65,12 @@ public final class InboundMeasurementResponses {
BigDecimal outerLengthCm,
BigDecimal outerWidthCm,
BigDecimal outerHeightCm,
String remark
String remark,
String boxCode,
Integer confirmedPackageCount,
String machineNo,
LocalDateTime deviceMeasuredAt,
LocalDateTime updatedAt
) {
}
@@ -17,7 +17,7 @@ public record InboundOrderSummary(
Long warehouseId, String warehouseName,
Long deliveryWarehouseId, String deliveryWarehouseName,
String businessType, String channelCode, String channelName,
String taxMode, String containerMode, Boolean customsInspection,
String taxMode, String pricingTaxMode, String containerMode, Boolean customsInspection,
String destinationType, Long destinationWarehouseId, String destinationCode, String destinationName,
Boolean holdFlag, String currentHoldReason,
Integer totalPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg,
@@ -40,7 +40,8 @@ public record InboundOrderSummary(
order.getExpectedArrivalAt(), order.getCustomerId(), order.getCustomerName(),
order.getCompanyId(), order.getCompanyName(), order.getWarehouseId(), order.getWarehouseName(),
order.getDeliveryWarehouseId(), order.getDeliveryWarehouseName(), order.getBusinessType(),
order.getChannelCode(), order.getChannelName(), order.getTaxMode(), order.getContainerMode(),
order.getChannelCode(), order.getChannelName(), order.getTaxMode(), order.getPricingTaxMode(),
order.getContainerMode(),
order.getCustomsInspection(), order.getDestinationType(), order.getDestinationWarehouseId(),
order.getDestinationCode(), order.getDestinationName(), order.getHoldFlag(),
order.getCurrentHoldReason(), order.getTotalPackageCount(), order.getActualGrossWeightKg(),
@@ -21,6 +21,7 @@ public record OrderApprovedInboundCommand(
Long carrierId,
String carrierName,
String taxMode,
String pricingTaxMode,
String containerMode,
Boolean customsInspection,
String destinationType,
@@ -31,4 +32,19 @@ public record OrderApprovedInboundCommand(
String expressCompanyName,
String expressTrackingNo
) {
/** 兼容未携带报价包税维度的历史调用方。 */
public OrderApprovedInboundCommand(
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, Boolean customsInspection,
String destinationType, Long destinationWarehouseId, String destinationCode, String destinationName,
String publicRemark, String expressCompanyName, String expressTrackingNo
) {
this(tenantId, orderId, orderNo, inboundCode, customerId, customerName, warehouseId, warehouseName,
deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName,
carrierId, carrierName, taxMode, null, containerMode, customsInspection, destinationType,
destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName,
expressTrackingNo);
}
}
@@ -0,0 +1,15 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public final class PriceBookChannelValidation {
private PriceBookChannelValidation() {}
public record Request(
@Min(1) long tenantId,
@Min(1) long originWarehouseId,
@NotBlank @Size(max = 64) String channelCode
) {}
}
@@ -0,0 +1,33 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
/**
* Confirmation only binds source warehouse dimensions to platform public warehouses.
* Price rows and channel metadata are immutable after workbook parsing.
*/
public record PriceBookConfirmationRequest(
@NotEmpty @Size(max = 2) List<@Valid OriginMapping> originMappings,
@Size(max = 2) List<@Valid OverseasMapping> overseasMappings
) {
public PriceBookConfirmationRequest {
overseasMappings = overseasMappings == null ? List.of() : List.copyOf(overseasMappings);
}
public record OriginMapping(
@NotBlank @Size(max = 64) String originWarehouseCode,
@NotNull @Min(1) Long warehouseId
) { }
public record OverseasMapping(
@NotBlank @Size(max = 128) String sourceDestinationName,
@NotNull @Min(1) Long warehouseId
) { }
}
@@ -0,0 +1,138 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.domain.PriceBookStatus;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PriceRegion;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.domain.PricingDestinationType;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 船期价格表上传、人工确认、当前渠道和参考估价的公共契约。 */
public final class PriceBookDtos {
private PriceBookDtos() {}
public record PriceBookVersionSummary(
Long id,
Integer versionNo,
String originalFileName,
Long sourceFileId,
String sourceSha256,
PriceBookStatus status,
Integer channelCount,
Integer rateCount,
Integer blockingErrorCount,
Integer warningCount,
String parseIssuesJson,
Long createdBy,
LocalDateTime createdAt,
Long publishedBy,
LocalDateTime publishedAt,
LocalDateTime supersededAt
) {}
public record PriceBookVersionDetail(
PriceBookVersionSummary version,
List<ParsedChannel> channels,
List<ParsedRate> rates,
List<ParseIssue> issues
) {}
/** Read-only channel data parsed from an uploaded workbook. */
public record ParsedChannel(
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@NotNull PriceModel priceModel,
@NotBlank @Size(max = 128) String sourceSheetName,
LocalDate estimatedDepartureDate,
@Size(max = 128) String estimatedDepartureText,
@Size(max = 128) String latestWarehouseEntry,
@Size(max = 128) String vesselName,
@Size(max = 64) String voyageNo,
@Size(max = 128) String referenceTransit,
@Size(max = 2000) String surchargeDescription,
@Size(max = 2000) String remark
) {}
/** Read-only rate row parsed from an uploaded workbook. */
public record ParsedRate(
Long id,
@NotBlank @Size(max = 64) String channelCode,
@NotNull PriceModel priceModel,
Long originWarehouseId,
@NotBlank @Size(max = 64) String originWarehouseCode,
@NotBlank @Size(max = 128) String originWarehouseName,
@Size(max = 32) String destinationCode,
PricingDestinationType destinationType,
Long destinationWarehouseId,
@Size(max = 128) String destinationName,
PriceRegion regionCode,
PricingTaxMode pricingTaxMode,
@NotBlank @Pattern(regexp = "KG|CBM") String chargeUnit,
@DecimalMin(value = "0", inclusive = true) BigDecimal weightFromKg,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal unitPrice,
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
@NotBlank @Size(max = 128) String sourceSheetName,
@NotNull @Min(1) Integer sourceRowNumber,
@Size(max = 1000) String sourceDestinationText,
@Size(max = 500) String remark
) {}
public record ParseIssue(
String severity,
String issueCode,
String message,
String sourceSheetName,
Integer sourceRowNumber,
String sourceCell
) {}
public record CurrentChannel(
Long priceBookVersionId,
String channelCode,
String channelName,
PriceModel priceModel,
List<PricingTaxMode> pricingTaxModes,
LocalDate estimatedDepartureDate,
String estimatedDepartureText,
String latestWarehouseEntry,
String referenceTransit,
String surchargeDescription,
String remark
) {}
public record EstimateRequest(
@NotNull @Min(1) Long originWarehouseId,
@NotBlank @Size(max = 64) String channelCode,
@Size(max = 32) String destinationCode,
@NotNull PricingDestinationType destinationType,
Long destinationWarehouseId,
@Size(max = 128) String destinationName,
PriceRegion destinationRegion,
PricingTaxMode pricingTaxMode,
@DecimalMin(value = "0", inclusive = true) BigDecimal actualWeightKg,
@DecimalMin(value = "0", inclusive = true) BigDecimal actualVolumeCbm
) {}
public record EstimateResponse(
Long priceBookVersionId,
String channelCode,
PriceModel priceModel,
PricingTaxMode pricingTaxMode,
BigDecimal chargeableQuantity,
String chargeUnit,
BigDecimal unitPrice,
BigDecimal estimatedAmount,
String currency,
String disclaimer
) {}
}
@@ -21,19 +21,31 @@ public class CarrierFbaRateEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long priceBookVersionId;
private String rateCode;
private Integer rateVersion;
private Long originWarehouseId;
private String originWarehouseCode;
private String originWarehouseName;
private String businessType;
private String channelCode;
private String priceModel;
private Long carrierId;
private String carrierName;
private String destinationType;
private String destinationCode;
private Long destinationWarehouseId;
private String destinationName;
private String regionCode;
private String pricingTaxMode;
private BigDecimal weightFromKg;
private String chargeUnit;
private BigDecimal unitPrice;
private String currency;
private BigDecimal additionalFee;
private String sourceSheetName;
private Integer sourceRowNumber;
private String sourceDestinationText;
private LocalDateTime effectiveFrom;
private LocalDateTime effectiveTo;
private String status;
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/** 客户填写真实尺寸后使用的每侧加宽配置。 */
@Getter
@Setter
@TableName("fl_customer_dimension_config")
public class CustomerDimensionConfigEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private BigDecimal edgeAdditionCm;
private String remark;
private String initializationKey;
}
@@ -0,0 +1,35 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 首次打印前冻结的实际箱号与订单货物明细映射。 */
@Getter
@Setter
@TableName("fl_inbound_carton_label")
public class InboundCartonLabelEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Long orderId;
private Long inboundDetailId;
private Integer cargoItemNo;
private String cargoName;
private Integer detailPackageCount;
private String inboundCode;
private String boxCode;
private Integer totalCartonCount;
private Integer cartonIndex;
private String boxSpecification;
private BigDecimal lengthCm;
private BigDecimal widthCm;
private BigDecimal heightCm;
private LocalDateTime createdAt;
}
@@ -24,11 +24,13 @@ public class InboundFeeEntity {
private Integer chargeRuleVersion;
private Long carrierRateId;
private Integer carrierRateVersion;
private Long priceBookVersionId;
private String ruleCode;
private Long customerId;
private String customerName;
private String businessType;
private String taxMode;
private String pricingTaxMode;
private Integer roundingScale;
private String roundingMode;
private LocalDateTime ruleEffectiveFrom;
@@ -39,6 +41,9 @@ public class InboundFeeEntity {
private String channelCode;
private Long carrierId;
private String carrierName;
private String destinationType;
private Long destinationWarehouseId;
private String destinationName;
private String destinationCode;
private LocalDateTime rateEffectiveFrom;
private LocalDateTime rateEffectiveTo;
@@ -20,6 +20,7 @@ public class InboundMeasurementItemEntity {
private Long measurementId;
private Long inboundDetailId;
private String externalDetailNo;
private String boxCode;
private String itemNo;
private String boxSpecification;
private Integer packageCount;
@@ -36,10 +37,13 @@ public class InboundMeasurementItemEntity {
private BigDecimal volumeWeightKg;
private String calculationRuleVersion;
private Long sourceImageFileId;
private String machineNo;
private LocalDateTime deviceMeasuredAt;
private String overLimitWarning;
private BigDecimal outerLengthCm;
private BigDecimal outerWidthCm;
private BigDecimal outerHeightCm;
private String remark;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -43,6 +43,7 @@ public class InboundOrderEntity extends BaseEntity {
private Long carrierId;
private String carrierName;
private String taxMode;
private String pricingTaxMode;
private String containerMode;
private Boolean customsInspection;
private String destinationType;
@@ -30,8 +30,11 @@ public class InboundPricingEntity extends BaseEntity {
private Long carrierId;
private String carrierName;
private String taxMode;
private String pricingTaxMode;
private String destinationType;
private String destinationCode;
private Long destinationWarehouseId;
private String destinationName;
private BigDecimal actualGrossWeightKg;
private BigDecimal actualVolumeCbm;
private BigDecimal chargeableWeightKg;
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDate;
@Getter @Setter
@TableName("fl_price_book_channel")
public class PriceBookChannelEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long priceBookVersionId;
private String channelCode;
private String channelName;
private String priceModel;
private String sourceSheetName;
private LocalDate estimatedDepartureDate;
private String estimatedDepartureText;
private String latestWarehouseEntry;
private String vesselName;
private String voyageNo;
private String referenceTransit;
private String surchargeDescription;
private String remark;
}
@@ -0,0 +1,31 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter
@TableName("fl_price_book_version")
public class PriceBookVersionEntity extends BaseEntity {
public static final long PLATFORM_SCOPE = 0L;
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long tenantId;
private Integer versionNo;
private String originalFileName;
private Long sourceFileId;
private String sourceSha256;
private String status;
private Integer blockingErrorCount;
private Integer warningCount;
private String parseIssuesJson;
private Integer channelCount;
private Integer rateCount;
private Long publishedBy;
private LocalDateTime publishedAt;
private LocalDateTime supersededAt;
}
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
@TableName("fl_price_channel_catalog")
public class PriceChannelCatalogEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String channelCode;
private String displayName;
private String priceModel;
private String sourceSheetName;
private String aliasesJson;
private String status;
}
@@ -0,0 +1,16 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
@TableName("fl_price_origin_alias")
public class PriceOriginAliasEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String warehouseCode;
private String aliasName;
private String status;
}
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@TableName("fl_price_warehouse_mapping")
public class PriceWarehouseMappingEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String sourceType;
private String sourceValue;
private Long warehouseId;
private String warehouseCode;
private String warehouseType;
private String status;
}
@@ -1,4 +1,10 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
public interface CarrierFbaRateMapper extends BaseMapper<CarrierFbaRateEntity> {}
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
public interface CarrierFbaRateMapper extends BaseMapper<CarrierFbaRateEntity> {
@Delete("DELETE FROM fl_carrier_fba_rate WHERE price_book_version_id = #{versionId} AND status = 'PENDING_CONFIRMATION'")
int physicallyDeletePendingByPriceBookVersionId(@Param("versionId") long versionId);
}
@@ -0,0 +1,37 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal;
public interface CustomerDimensionConfigMapper extends BaseMapper<CustomerDimensionConfigEntity> {
@Insert("""
INSERT IGNORE INTO fl_customer_dimension_config
(id, tenant_id, edge_addition_cm, remark, initialization_key, created_by, created_at,
updated_by, updated_at, deleted, version)
VALUES
(#{id}, #{tenantId}, #{edgeAdditionCm}, #{remark}, #{initializationKey}, #{operatorId}, CURRENT_TIMESTAMP(3),
#{operatorId}, CURRENT_TIMESTAMP(3), 0, 0)
""")
int insertDefault(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("edgeAdditionCm") BigDecimal edgeAdditionCm,
@Param("remark") String remark, @Param("initializationKey") String initializationKey,
@Param("operatorId") long operatorId);
@Delete("""
DELETE FROM fl_customer_dimension_config
WHERE id = #{id}
AND tenant_id = #{tenantId}
AND initialization_key = #{initializationKey}
AND edge_addition_cm = #{edgeAdditionCm}
AND version = 0
AND deleted = 0
""")
int deleteUnchangedDefault(@Param("tenantId") long tenantId, @Param("id") long id,
@Param("initializationKey") String initializationKey,
@Param("edgeAdditionCm") BigDecimal edgeAdditionCm);
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundCartonLabelEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundCartonLabelMapper extends BaseMapper<InboundCartonLabelEntity> {
}
@@ -0,0 +1,10 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.PriceBookChannelEntity;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
public interface PriceBookChannelMapper extends BaseMapper<PriceBookChannelEntity> {
@Delete("DELETE FROM fl_price_book_channel WHERE price_book_version_id = #{versionId}")
int physicallyDeleteByPriceBookVersionId(@Param("versionId") long versionId);
}
@@ -0,0 +1,141 @@
package com.baiyue.firstleg.mapper;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/** Dedicated read model for merchant channel selection and reference estimates. */
@Mapper
public interface PriceBookQueryMapper {
@Select("""
SELECT id
FROM fl_price_book_version
WHERE tenant_id = 0 AND status = 'ACTIVE' AND deleted = 0
ORDER BY published_at DESC, version_no DESC
LIMIT 1
""")
Long findActiveVersionId();
@Select("""
SELECT c.price_book_version_id AS priceBookVersionId,
c.channel_code AS channelCode, c.channel_name AS channelName,
c.price_model AS priceModel,
c.estimated_departure_date AS estimatedDepartureDate,
c.estimated_departure_text AS estimatedDepartureText,
c.latest_warehouse_entry AS latestWarehouseEntry,
c.reference_transit AS referenceTransit,
c.surcharge_description AS surchargeDescription,
c.remark AS remark
FROM fl_price_book_channel c
WHERE c.price_book_version_id = #{versionId}
AND EXISTS (
SELECT 1 FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = c.price_book_version_id
AND r.channel_code = c.channel_code
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
AND r.status = 'ACTIVE' AND r.deleted = 0
)
ORDER BY c.id
""")
List<ChannelRow> findChannels(@Param("versionId") long versionId,
@Param("originWarehouseId") long originWarehouseId);
@Select("""
SELECT r.channel_code AS channelCode, r.pricing_tax_mode AS pricingTaxMode
FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = #{versionId}
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
AND r.status = 'ACTIVE' AND r.deleted = 0
AND r.pricing_tax_mode IS NOT NULL
GROUP BY r.channel_code, r.pricing_tax_mode
ORDER BY r.channel_code, r.pricing_tax_mode
""")
List<ChannelTaxModeRow> findChannelTaxModes(@Param("versionId") long versionId,
@Param("originWarehouseId") long originWarehouseId);
@Select("""
SELECT r.id, r.price_book_version_id AS priceBookVersionId,
r.rate_code AS rateCode, r.rate_version AS rateVersion,
r.origin_warehouse_id AS originWarehouseId, r.origin_warehouse_name AS originWarehouseName,
r.channel_code AS channelCode, r.price_model AS priceModel,
r.destination_type AS destinationType, r.destination_code AS destinationCode,
r.destination_warehouse_id AS destinationWarehouseId, r.destination_name AS destinationName,
r.region_code AS regionCode,
r.pricing_tax_mode AS pricingTaxMode, r.charge_unit AS chargeUnit,
r.weight_from_kg AS weightFromKg, r.unit_price AS unitPrice,
r.currency AS currency, r.additional_fee AS additionalFee,
r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo
FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = #{versionId}
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
AND r.channel_code = #{channelCode} AND r.destination_type = 'FBA'
AND UPPER(r.destination_code) = #{destinationCode}
AND r.pricing_tax_mode = #{pricingTaxMode}
AND r.status = 'ACTIVE' AND r.deleted = 0
""")
List<RateRow> findFbaRates(@Param("versionId") long versionId,
@Param("originWarehouseId") long originWarehouseId,
@Param("channelCode") String channelCode,
@Param("destinationCode") String destinationCode,
@Param("pricingTaxMode") String pricingTaxMode);
@Select("""
SELECT r.id, r.price_book_version_id AS priceBookVersionId,
r.rate_code AS rateCode, r.rate_version AS rateVersion,
r.origin_warehouse_id AS originWarehouseId, r.origin_warehouse_name AS originWarehouseName,
r.channel_code AS channelCode, r.price_model AS priceModel,
r.destination_type AS destinationType, r.destination_code AS destinationCode,
r.destination_warehouse_id AS destinationWarehouseId, r.destination_name AS destinationName,
r.region_code AS regionCode,
r.pricing_tax_mode AS pricingTaxMode, r.charge_unit AS chargeUnit,
r.weight_from_kg AS weightFromKg, r.unit_price AS unitPrice,
r.currency AS currency, r.additional_fee AS additionalFee,
r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo
FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = #{versionId}
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
AND r.channel_code = #{channelCode} AND r.destination_type = 'OVERSEAS'
AND r.destination_warehouse_id = #{destinationWarehouseId}
AND r.pricing_tax_mode = #{pricingTaxMode}
AND r.status = 'ACTIVE' AND r.deleted = 0
""")
List<RateRow> findOverseasRates(@Param("versionId") long versionId,
@Param("originWarehouseId") long originWarehouseId,
@Param("channelCode") String channelCode,
@Param("destinationWarehouseId") long destinationWarehouseId,
@Param("pricingTaxMode") String pricingTaxMode);
@Select("""
SELECT r.id, r.price_book_version_id AS priceBookVersionId,
r.rate_code AS rateCode, r.rate_version AS rateVersion,
r.origin_warehouse_id AS originWarehouseId, r.origin_warehouse_name AS originWarehouseName,
r.channel_code AS channelCode, r.price_model AS priceModel,
r.destination_type AS destinationType, r.destination_code AS destinationCode,
r.destination_warehouse_id AS destinationWarehouseId, r.destination_name AS destinationName,
r.region_code AS regionCode,
r.pricing_tax_mode AS pricingTaxMode, r.charge_unit AS chargeUnit,
r.weight_from_kg AS weightFromKg, r.unit_price AS unitPrice,
r.currency AS currency, r.additional_fee AS additionalFee,
r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo
FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = #{versionId}
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
AND r.channel_code = #{channelCode}
AND r.price_model = 'REGION_WEIGHT_TIER'
AND r.region_code = #{regionCode}
AND ((#{pricingTaxMode} IS NULL AND r.pricing_tax_mode IS NULL)
OR r.pricing_tax_mode = #{pricingTaxMode})
AND r.status = 'ACTIVE' AND r.deleted = 0
ORDER BY r.weight_from_kg
""")
List<RateRow> findRegionTiers(@Param("versionId") long versionId,
@Param("originWarehouseId") long originWarehouseId,
@Param("channelCode") String channelCode,
@Param("regionCode") String regionCode,
@Param("pricingTaxMode") String pricingTaxMode);
}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.PriceBookVersionEntity;
public interface PriceBookVersionMapper extends BaseMapper<PriceBookVersionEntity> {}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.PriceChannelCatalogEntity;
public interface PriceChannelCatalogMapper extends BaseMapper<PriceChannelCatalogEntity> {}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.PriceOriginAliasEntity;
public interface PriceOriginAliasMapper extends BaseMapper<PriceOriginAliasEntity> {}
@@ -0,0 +1,6 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.PriceWarehouseMappingEntity;
public interface PriceWarehouseMappingMapper extends BaseMapper<PriceWarehouseMappingEntity> {}
@@ -0,0 +1,150 @@
package com.baiyue.firstleg.pricing;
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.PriceRegion;
import com.baiyue.firstleg.domain.PricingDestinationType;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.util.List;
import java.util.Locale;
/** Single active-price-book lookup used by merchant estimates and inbound automatic billing. */
@Component
public class ActivePriceBookEstimator {
@Resource private PriceBookQueryRepository repository;
@Resource private FbaWarehouseRateEstimator fbaEstimator;
@Resource private RegionWeightTierEstimator regionEstimator;
@Resource private PriceBookWarehouseRegionResolver warehouseRegionResolver;
public Estimate estimate(EstimateRequest request) {
if (request == null || request.originWarehouseId() == null || request.originWarehouseId() <= 0) {
throw validation("请选择有效起运仓");
}
Long versionId = repository.activeVersionId();
if (versionId == null) {
throw new BizException(ResultCode.NOT_FOUND, "当前没有生效的船期价格版本");
}
String channelCode = normalizeChannel(request.channelCode());
ChannelRow channel = repository.channels(versionId, request.originWarehouseId()).stream()
.filter(value -> channelCode.equals(value.getChannelCode()))
.findFirst()
.orElseThrow(() -> new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择"));
return switch (priceModel(channel.getPriceModel())) {
case FBA_WAREHOUSE_RATE -> estimateFba(versionId, channelCode, request);
case REGION_WEIGHT_TIER -> estimateRegion(versionId, channelCode, request);
};
}
private Estimate estimateFba(long versionId, String channelCode, EstimateRequest request) {
if (request.pricingTaxMode() == null) {
throw validation("请选择包税或不包税报价");
}
List<RateRow> rates;
if (request.destinationType() == PricingDestinationType.FBA) {
if (!StringUtils.hasText(request.destinationCode())) {
throw validation("请选择有效FBA目的仓");
}
rates = repository.fbaRates(versionId, request.originWarehouseId(), channelCode,
request.destinationCode().trim().toUpperCase(Locale.ROOT), request.pricingTaxMode().name());
} else if (request.destinationType() == PricingDestinationType.OVERSEAS) {
if (request.destinationWarehouseId() == null || request.destinationWarehouseId() <= 0) {
throw validation("请选择有效海外仓");
}
rates = repository.overseasRates(versionId, request.originWarehouseId(), channelCode,
request.destinationWarehouseId(), request.pricingTaxMode().name());
} else {
throw validation("私人地址暂不支持参考估价");
}
RateRow rate = uniqueRate(rates);
requireModel(rate, PriceModel.FBA_WAREHOUSE_RATE);
var estimate = fbaEstimator.estimate(rate, request.actualWeightKg(), request.actualVolumeCbm());
return new Estimate(versionId, channelCode, PriceModel.FBA_WAREHOUSE_RATE, request.pricingTaxMode(),
rate, estimate.quantity(), estimate.unit(), estimate.unitPrice(), estimate.amount(), estimate.currency());
}
private Estimate estimateRegion(long versionId, String channelCode, EstimateRequest request) {
if (request.destinationType() != PricingDestinationType.FBA) {
throw validation("海派渠道仅支持FBA目的仓参考估价");
}
if (request.pricingTaxMode() == null) {
throw validation("请选择包税或不包税报价");
}
PriceRegion region = request.destinationRegion() == null
? resolveWarehouseRegion(request.destinationWarehouseId()) : request.destinationRegion();
List<RateRow> tiers = repository.regionTiers(versionId, request.originWarehouseId(), channelCode,
region.name(), request.pricingTaxMode().name());
if (tiers == null || tiers.isEmpty()) {
throw new BizException(ResultCode.NOT_FOUND, "当前渠道没有匹配的区域价格");
}
tiers.forEach(rate -> requireModel(rate, PriceModel.REGION_WEIGHT_TIER));
var estimate = regionEstimator.estimate(tiers, request.actualWeightKg(), request.actualVolumeCbm());
RateRow selected = estimate.rate();
return new Estimate(versionId, channelCode, PriceModel.REGION_WEIGHT_TIER, request.pricingTaxMode(),
selected, estimate.quantity(), "KG", estimate.unitPrice(), estimate.amount(), estimate.currency());
}
private PriceRegion resolveWarehouseRegion(Long warehouseId) {
if (warehouseId == null || warehouseId <= 0) {
throw validation("请提供目的仓区域或平台FBA仓,以确定海派区域");
}
return warehouseRegionResolver.resolve(warehouseId);
}
private static RateRow uniqueRate(List<RateRow> rates) {
if (rates == null || rates.isEmpty()) {
throw new BizException(ResultCode.NOT_FOUND, "当前条件没有匹配价格");
}
if (rates.size() != 1) {
throw new BizException(ResultCode.CONFLICT, "当前条件匹配到多条价格,请联系平台处理");
}
return rates.get(0);
}
private static void requireModel(RateRow rate, PriceModel expected) {
if (rate == null || !expected.name().equals(rate.getPriceModel())) {
throw new BizException(ResultCode.CONFLICT, "价格模型与渠道配置不一致");
}
}
private static PriceModel priceModel(String value) {
try {
return PriceModel.valueOf(value);
} catch (RuntimeException exception) {
throw new BizException(ResultCode.CONFLICT, "渠道价格模型配置无效");
}
}
private static String normalizeChannel(String value) {
if (!StringUtils.hasText(value)) {
throw validation("请选择有效渠道");
}
return value.trim().toUpperCase(Locale.ROOT);
}
private static BizException validation(String message) {
return new BizException(ResultCode.VALIDATION_FAILED, message);
}
public record Estimate(
Long priceBookVersionId,
String channelCode,
PriceModel priceModel,
PricingTaxMode pricingTaxMode,
RateRow rate,
BigDecimal chargeableQuantity,
String chargeUnit,
BigDecimal unitPrice,
BigDecimal amount,
String currency
) {}
}
@@ -0,0 +1,40 @@
package com.baiyue.firstleg.pricing;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
@Component
public class FbaWarehouseRateEstimator {
private static final BigDecimal KG_PER_CBM = BigDecimal.valueOf(167);
private static final BigDecimal KG_PER_BILLABLE_CBM = BigDecimal.valueOf(363);
public Estimate estimate(RateRow rate, BigDecimal actualWeightKg, BigDecimal actualVolumeCbm) {
BigDecimal weight = nonNegative(actualWeightKg);
BigDecimal volume = nonNegative(actualVolumeCbm);
if (weight.signum() == 0 && volume.signum() == 0) {
throw new BizException(ResultCode.VALIDATION_FAILED, "重量和体积至少填写一项且必须大于0");
}
BigDecimal quantity = switch (rate.getChargeUnit()) {
case "KG" -> weight.max(volume.multiply(KG_PER_CBM));
case "CBM" -> volume.max(weight.divide(KG_PER_BILLABLE_CBM, 12, RoundingMode.HALF_UP));
default -> throw new BizException(ResultCode.CONFLICT, "当前价格计费单位不受支持");
};
quantity = quantity.setScale(6, RoundingMode.HALF_UP);
return new Estimate(quantity, rate.getChargeUnit(), rate.getUnitPrice(),
quantity.multiply(rate.getUnitPrice()).setScale(2, RoundingMode.HALF_UP), rate.getCurrency());
}
private static BigDecimal nonNegative(BigDecimal value) {
if (value == null) return BigDecimal.ZERO;
if (value.signum() < 0) throw new BizException(ResultCode.VALIDATION_FAILED, "重量和体积不能小于0");
return value;
}
public record Estimate(BigDecimal quantity, String unit, BigDecimal unitPrice,
BigDecimal amount, String currency) {}
}
@@ -0,0 +1,54 @@
package com.baiyue.firstleg.pricing;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.PriceBookWarehouseClient;
import com.baiyue.firstleg.domain.PriceRegion;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/** Resolves a platform FBA warehouse to the fixed sea-parcel price region. */
@Component
public class PriceBookWarehouseRegionResolver {
@Resource private PriceBookWarehouseClient warehouses;
@Resource private PriceRegionResolver regionResolver;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public PriceRegion resolve(long warehouseId) {
if (warehouseId <= 0) {
throw validation("请提供目的仓区域或平台FBA仓,以确定海派区域");
}
try {
ApiResponse<PriceBookWarehouseClient.Detail> response = warehouses.active(internalToken, 0L, warehouseId);
if (response == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "目的仓服务暂时不可用");
}
if (!response.successful() || response.data() == null || response.data().warehouse() == null) {
throw validation("目的仓不是可用的平台FBA仓");
}
var warehouse = response.data().warehouse();
if (!Long.valueOf(warehouseId).equals(warehouse.id()) || !Long.valueOf(0L).equals(warehouse.tenantId())
|| !"FBA".equals(warehouse.warehouseType()) || !"ACTIVE".equals(warehouse.status())) {
throw validation("目的仓不是可用的平台FBA仓");
}
String postalCode = response.data().addresses() == null ? null : response.data().addresses().stream()
.filter(address -> address != null && StringUtils.hasText(address.postalCode()))
.map(PriceBookWarehouseClient.Address::postalCode)
.findFirst()
.orElse(null);
return regionResolver.fromPostalCode(postalCode);
} catch (BizException exception) {
throw exception;
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "目的仓服务暂时不可用");
}
}
private static BizException validation(String message) {
return new BizException(ResultCode.VALIDATION_FAILED, message);
}
}
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.pricing;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.PriceRegion;
import org.springframework.stereotype.Component;
@Component
public class PriceRegionResolver {
public PriceRegion fromPostalCode(String rawPostalCode) {
String digits = rawPostalCode == null ? "" : rawPostalCode.replaceAll("[^0-9]", "");
if (digits.isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "目的仓未配置可识别的美国邮编,无法确定海派区域");
}
if (digits.startsWith("46")) return PriceRegion.EAST;
return switch (digits.charAt(0)) {
case '0', '1', '2', '3' -> PriceRegion.EAST;
case '4', '5', '6', '7' -> PriceRegion.CENTRAL;
case '8', '9' -> PriceRegion.WEST;
default -> throw new BizException(ResultCode.VALIDATION_FAILED, "目的仓邮编无法确定海派区域");
};
}
}
@@ -0,0 +1,42 @@
package com.baiyue.firstleg.pricing;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Comparator;
import java.util.List;
@Component
public class RegionWeightTierEstimator {
private static final BigDecimal KG_PER_CBM = BigDecimal.valueOf(167);
public Estimate estimate(List<RateRow> tiers, BigDecimal actualWeightKg, BigDecimal actualVolumeCbm) {
BigDecimal weight = nonNegative(actualWeightKg);
BigDecimal volume = nonNegative(actualVolumeCbm);
if (weight.signum() == 0 && volume.signum() == 0) {
throw new BizException(ResultCode.VALIDATION_FAILED, "重量和体积至少填写一项且必须大于0");
}
BigDecimal quantity = weight.max(volume.multiply(KG_PER_CBM)).setScale(6, RoundingMode.HALF_UP);
RateRow selected = tiers.stream()
.filter(rate -> "KG".equals(rate.getChargeUnit()) && rate.getWeightFromKg() != null)
.filter(rate -> rate.getWeightFromKg().compareTo(quantity) <= 0)
.max(Comparator.comparing(RateRow::getWeightFromKg))
.orElseThrow(() -> new BizException(ResultCode.VALIDATION_FAILED,
"当前计费重量未达到该海派渠道的最低重量阶梯"));
return new Estimate(selected, quantity, selected.getUnitPrice(),
quantity.multiply(selected.getUnitPrice()).setScale(2, RoundingMode.HALF_UP), selected.getCurrency());
}
private static BigDecimal nonNegative(BigDecimal value) {
if (value == null) return BigDecimal.ZERO;
if (value.signum() < 0) throw new BizException(ResultCode.VALIDATION_FAILED, "重量和体积不能小于0");
return value;
}
public record Estimate(RateRow rate, BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
String currency) {}
}
@@ -0,0 +1,90 @@
package com.baiyue.firstleg.pricing.importer;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PricingTaxMode;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/** Pure, persistence-free result of parsing the platform price workbook. */
public record PriceWorkbookParseResult(
List<ParsedChannel> channels,
List<ParseIssue> issues,
List<String> ignoredSheets) {
public PriceWorkbookParseResult {
channels = List.copyOf(channels == null ? List.of() : channels);
issues = List.copyOf(issues == null ? List.of() : issues);
ignoredSheets = List.copyOf(ignoredSheets == null ? List.of() : ignoredSheets);
}
public boolean hasBlockingErrors() {
return issues.stream().anyMatch(issue -> issue.severity() == Severity.ERROR);
}
public long rateCount() {
return channels.stream().mapToLong(channel -> channel.rates().size()).sum();
}
public enum Severity { ERROR, WARNING }
public record ParseIssue(
Severity severity,
String code,
String message,
String sheetName,
Integer rowNumber,
String rawValue) {
public ParseIssue {
severity = severity == null ? Severity.WARNING : severity;
code = code == null ? "UNKNOWN" : code;
}
}
public record ParsedSailing(
String rawText,
String vesselName,
String voyageNumber,
LocalDate expectedDepartureDate) { }
public record ParsedChannel(
String channelCode,
String channelName,
String sourceSheet,
String sourceLabel,
PriceModel priceModel,
List<ParsedSailing> sailings,
String scheduleText,
String cutoffText,
String transitTimeText,
String surchargeText,
String remarkText,
List<ParsedRate> rates) {
public ParsedChannel {
sailings = List.copyOf(sailings == null ? List.of() : sailings);
rates = List.copyOf(rates == null ? List.of() : rates);
}
}
public record ParsedRate(
String sourceSheet,
int sourceRow,
String originWarehouseKey,
String originWarehouseLabel,
String rawDestinationGroup,
String destinationCode,
String regionCode,
BigDecimal minimumWeightKg,
PricingTaxMode taxMode,
String chargeUnit,
BigDecimal unitPrice,
String transitTimeText,
String cutoffText,
String scheduleText,
String surchargeText,
String remarkText,
/** Source-level destination classification; mapped to the public contract at the boundary. */
String destinationType,
String destinationName) { }
}
@@ -0,0 +1,418 @@
package com.baiyue.firstleg.pricing.importer;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PricingTaxMode;
import org.apache.poi.openxml4j.opc.OPCPackage;
import org.apache.poi.openxml4j.opc.PackageAccess;
import org.apache.poi.xssf.eventusermodel.ReadOnlySharedStringsTable;
import org.apache.poi.xssf.eventusermodel.XSSFReader;
import org.apache.poi.xssf.eventusermodel.XSSFSheetXMLHandler;
import org.apache.poi.xssf.model.StylesTable;
import org.apache.poi.xssf.usermodel.XSSFComment;
import org.apache.poi.util.XMLHelper;
import org.xml.sax.InputSource;
import org.xml.sax.XMLReader;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult.ParseIssue;
import static com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult.ParsedChannel;
import static com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult.ParsedRate;
import static com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult.ParsedSailing;
import static com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult.Severity;
/**
* Parses the supported price tabs without consulting Excel Table metadata or UsedRange.
* The SAX reader only visits real cells in supported sheets, so malformed Table refs and
* inflated dimensions in the workbook do not prevent the price tabs from being read.
*/
public final class PriceWorkbookParser {
private static final int MAX_ROWS = 200;
private static final int MAX_COLUMNS = 32;
private static final Pattern CODE_PATTERN = Pattern.compile("(?<![A-Z0-9])[A-Z][A-Z0-9]{2,9}(?![A-Z0-9])");
private static final Pattern DATE_PATTERN = Pattern.compile("预计开船日期\\s*[::]?\\s*(\\d{4})年(\\d{1,2})月(\\d{1,2})日");
private static final Pattern SAILING_PATTERN = Pattern.compile("船名航次\\s*[::]\\s*(.+?)\\s+V\\.([A-Za-z0-9-]+)");
private static final Pattern NUMBER_PATTERN = Pattern.compile("[-+]?\\d+(?:[,.]\\d+)?");
private static final List<StandardTab> STANDARD_TABS = List.of(
new StandardTab("美森正班海卡", "MATSON_CLX_TRUCK"),
new StandardTab("美森加班海卡", "MATSON_MAX_TRUCK"),
new StandardTab("EXX海卡", "EXX_TRUCK"),
new StandardTab("合德海卡", "HEDE_TRUCK"),
new StandardTab("纽约EXX海铁", "EXX_RAIL_NY"),
new StandardTab("洛杉矶普船海卡", "OA_LA_TRUCK"),
new StandardTab("洛杉矶普船全卡", "OA_LA_FULL_TRUCK"),
new StandardTab("芝加哥普船海铁", "OA_CHICAGO_RAIL"),
new StandardTab("休斯顿普船海卡", "OA_HOUSTON_TRUCK"),
new StandardTab("纽约普船海卡", "OA_NEW_YORK_TRUCK"),
new StandardTab("萨凡纳普船海卡", "OA_SAVANNAH_TRUCK"));
private static final List<ParcelChannel> PARCEL_CHANNELS = List.of(
new ParcelChannel("MATSON_CLX_PARCEL", "美森正班海派"),
new ParcelChannel("MATSON_MAX_PARCEL", "美森加班海派"),
new ParcelChannel("HEDE_PARCEL", "合德海派"),
new ParcelChannel("OA_PARCEL", "普船海派"));
private static final Set<String> IGNORED_SHEETS = Set.of(
"出货注意事项", "UPS派送售后服务指南", "快速查询", "UPS&FedEx偏远地区表",
"一件代发收费", "FDA申报清单", "FBA分区表", "亚马逊仓库代码表");
public PriceWorkbookParseResult parse(Path workbook) throws IOException {
if (workbook == null) throw new IllegalArgumentException("workbook must not be null");
try (OPCPackage packageRef = OPCPackage.open(workbook.toFile(), PackageAccess.READ)) {
return parse(packageRef);
} catch (Exception exception) {
if (exception instanceof IOException io) throw io;
throw new IOException("无法读取 Excel 工作簿", exception);
}
}
private PriceWorkbookParseResult parse(OPCPackage packageRef) throws Exception {
XSSFReader reader = new XSSFReader(packageRef);
StylesTable styles = reader.getStylesTable();
ReadOnlySharedStringsTable sharedStrings = new ReadOnlySharedStringsTable(packageRef);
Map<String, SheetData> sheets = new LinkedHashMap<>();
Set<String> allSheetNames = new LinkedHashSet<>();
XSSFReader.SheetIterator iterator = (XSSFReader.SheetIterator) reader.getSheetsData();
while (iterator.hasNext()) {
try (InputStream stream = iterator.next()) {
String sheetName = iterator.getSheetName();
allSheetNames.add(sheetName);
if (!isSupportedSheet(sheetName)) continue;
sheets.put(sheetName, readSheet(stream, styles, sharedStrings));
}
}
List<ParsedChannel> channels = new ArrayList<>();
List<ParseIssue> issues = new ArrayList<>();
List<String> ignored = new ArrayList<>();
for (String sheetName : allSheetNames) {
if (IGNORED_SHEETS.contains(sheetName)) ignored.add(sheetName);
}
for (StandardTab tab : STANDARD_TABS) {
SheetData data = sheets.get(tab.sheetName());
if (data == null) {
issues.add(issue(Severity.WARNING, "CHANNEL_SHEET_MISSING",
"标准渠道 Sheet 未找到,本版本不会提供该渠道", tab.sheetName(), null, null));
continue;
}
channels.add(parseStandard(tab, data, issues));
}
SheetData parcel = sheets.get("海派");
if (parcel == null) {
issues.add(issue(Severity.WARNING, "CHANNEL_SHEET_MISSING",
"海派 Sheet 未找到,本版本不会提供海派渠道", "海派", null, null));
} else {
channels.addAll(parseParcel(parcel, issues));
}
return new PriceWorkbookParseResult(channels, issues, ignored);
}
private static boolean isSupportedSheet(String name) {
return "海派".equals(name) || STANDARD_TABS.stream().anyMatch(tab -> tab.sheetName().equals(name));
}
private static SheetData readSheet(InputStream stream, StylesTable styles,
ReadOnlySharedStringsTable sharedStrings) throws Exception {
SheetData data = new SheetData();
XMLReader parser = XMLHelper.newXMLReader();
parser.setContentHandler(new XSSFSheetXMLHandler(styles, null, sharedStrings,
new CellHandler(data), new org.apache.poi.ss.usermodel.DataFormatter(Locale.CHINA), false));
parser.parse(new InputSource(stream));
return data;
}
private static ParsedChannel parseStandard(StandardTab tab, SheetData data, List<ParseIssue> issues) {
int headerRow = findRowContaining(data, "FBA仓库");
if (headerRow < 0) {
issues.add(issue(Severity.ERROR, "STANDARD_HEADER_NOT_FOUND",
"未识别标准海卡/海铁表头", tab.sheetName(), null, null));
return new ParsedChannel(tab.channelCode(), tab.sheetName(), tab.sheetName(), tab.sheetName(),
PriceModel.FBA_WAREHOUSE_RATE, sailings(data.value(2, 0)), null, null, null, null, null, List.of());
}
int base = findCellColumn(data, headerRow, "FBA仓库");
List<ParsedRate> rates = new ArrayList<>();
String schedule = null, cutoff = null, transit = null, surcharge = null, remark = null;
for (int row = headerRow + 2; row <= MAX_ROWS; row++) {
String destination = clean(data.value(row, base));
if (destination == null) continue;
String nonTax = clean(data.value(row, base + 1));
String eastTax = clean(data.value(row, base + 2));
String southTax = clean(data.value(row, base + 3));
if (!hasPrice(nonTax) && !hasPrice(eastTax) && !hasPrice(southTax)) continue;
String rowTransit = clean(data.value(row, base + 4));
String rowCutoff = clean(data.value(row, base + 5));
String rowSchedule = clean(data.value(row, base + 6));
String rowSurcharge = clean(data.value(row, base + 7));
String rowRemark = clean(data.value(row, base + 8));
transit = firstNonBlank(transit, rowTransit);
cutoff = firstNonBlank(cutoff, rowCutoff);
schedule = firstNonBlank(schedule, rowSchedule);
surcharge = firstNonBlank(surcharge, rowSurcharge);
remark = firstNonBlank(remark, rowRemark);
List<String> codes = extractCodes(destination);
boolean overseas = destination.contains("海外仓");
if (codes.isEmpty() && !overseas) {
issues.add(issue(Severity.ERROR, "DESTINATION_MAPPING_REQUIRED",
"目的仓文本未识别为 FBA 仓库代码,需要确认映射或删除该行", tab.sheetName(), row, destination));
codes = Collections.singletonList(null);
}
if (codes.isEmpty()) codes = Collections.singletonList(null);
for (String code : codes) {
addStandardRate(rates, issues, tab.sheetName(), row, code, destination,
"YIWU", "华东仓(义乌)", nonTax, PricingTaxMode.TAX_EXCLUDED, "CBM", rowTransit,
rowCutoff, rowSchedule, rowSurcharge, rowRemark);
addStandardRate(rates, issues, tab.sheetName(), row, code, destination,
"YIWU", "华东仓(义乌)", eastTax, PricingTaxMode.TAX_INCLUDED, "KG", rowTransit,
rowCutoff, rowSchedule, rowSurcharge, rowRemark);
addStandardRate(rates, issues, tab.sheetName(), row, code, destination,
"DONGGUAN", "华南仓(深圳/东莞)", southTax, PricingTaxMode.TAX_INCLUDED, "KG", rowTransit,
rowCutoff, rowSchedule, rowSurcharge, rowRemark);
}
}
if (rates.isEmpty()) {
issues.add(issue(Severity.ERROR, "NO_STANDARD_PRICES", "标准渠道未解析出价格行", tab.sheetName(), null, null));
}
List<ParsedSailing> sailings = sailings(data.value(2, 0));
if (sailings.isEmpty() && clean(data.value(2, 0)) != null) {
issues.add(issue(Severity.WARNING, "SAILING_NOT_RECOGNIZED",
"船名航次文本未能结构化识别,保留原始文本", tab.sheetName(), 2, data.value(2, 0)));
}
return new ParsedChannel(tab.channelCode(), tab.sheetName(), tab.sheetName(), tab.sheetName(),
PriceModel.FBA_WAREHOUSE_RATE, sailings, schedule, cutoff, transit, surcharge, remark, rates);
}
private static void addStandardRate(List<ParsedRate> rates, List<ParseIssue> issues,
String sheet, int row, String code, String rawDestination,
String originKey, String originLabel, String rawPrice,
PricingTaxMode taxMode, String unit, String transit, String cutoff,
String schedule, String surcharge, String remark) {
BigDecimal price = parsePrice(rawPrice);
if (price == null) return;
String destinationType = rawDestination != null && rawDestination.contains("海外仓")
? "OVERSEAS" : code != null ? "FBA" : null;
String destinationName = "OVERSEAS".equals(destinationType)
? normalizeOverseasName(rawDestination) : code;
rates.add(new ParsedRate(sheet, row, originKey, originLabel, rawDestination, code, null, null,
taxMode, unit, price, transit, cutoff, schedule, surcharge, remark,
destinationType, destinationName));
}
private static String normalizeOverseasName(String raw) {
if (raw == null) return null;
if (raw.contains("洛杉矶")) return "洛杉矶仓";
if (raw.contains("纽约")) return "纽约仓";
int open = raw.indexOf('(');
int close = raw.indexOf(')', open + 1);
if (open >= 0 && close > open) return raw.substring(open + 1, close).trim();
return raw.replace("海外仓", "").trim();
}
private static List<ParsedChannel> parseParcel(SheetData data, List<ParseIssue> issues) {
Map<String, ChannelBuilder> builders = new LinkedHashMap<>();
PARCEL_CHANNELS.forEach(channel -> builders.put(channel.code(),
new ChannelBuilder(channel.code(), channel.name())));
parseParcelOrigin(data, 5, 8, 9, "YIWU", "华东仓(义乌)", builders, issues);
parseParcelOrigin(data, 14, 17, 18, "DONGGUAN", "华南仓(深圳/东莞)", builders, issues);
List<ParsedChannel> result = new ArrayList<>();
for (ChannelBuilder builder : builders.values()) {
if (builder.rates.isEmpty()) {
issues.add(issue(Severity.ERROR, "NO_PARCEL_PRICES", "海派渠道未解析出价格阶梯", "海派", null, builder.name));
}
result.add(builder.toChannel());
}
return result;
}
private static void parseParcelOrigin(SheetData data, int scheduleRow, int tierRow,
int firstRegionRow, String originKey, String originLabel,
Map<String, ChannelBuilder> builders, List<ParseIssue> issues) {
int[] starts = {2, 5, 8, 11};
for (int channel = 0; channel < starts.length; channel++) {
int start = starts[channel];
ChannelBuilder builder = builders.get(PARCEL_CHANNELS.get(channel).code());
builder.scheduleText = firstNonBlank(builder.scheduleText, data.value(scheduleRow, start));
builder.transitText = firstNonBlank(builder.transitText, data.value(scheduleRow + 1, start));
builder.surchargeText = firstNonBlank(builder.surchargeText, data.value(scheduleRow, 14));
builder.remarkText = firstNonBlank(builder.remarkText, data.value(scheduleRow, 15));
int[] thresholds = {12, 51, 101};
for (int row = firstRegionRow; row < firstRegionRow + 3; row++) {
String rawRegion = clean(data.value(row, 0));
String region = regionCode(rawRegion);
if (region == null) {
issues.add(issue(Severity.ERROR, "REGION_NOT_RECOGNIZED",
"海派区域未识别", "海派", row, rawRegion));
continue;
}
for (int tier = 0; tier < thresholds.length; tier++) {
BigDecimal price = parsePrice(data.value(row, start + tier));
if (price == null) {
issues.add(issue(Severity.ERROR, "PARCEL_PRICE_MISSING",
"海派重量阶梯价格缺失", "海派", row, data.value(row, start + tier)));
continue;
}
builder.rates.add(new ParsedRate("海派", row, originKey, originLabel, rawRegion, null,
region, BigDecimal.valueOf(thresholds[tier]), PricingTaxMode.TAX_INCLUDED, "KG", price,
builder.transitText, null, builder.scheduleText, builder.surchargeText, builder.remarkText,
"REGION_WEIGHT_TIER", rawRegion));
}
}
}
}
private static List<String> extractCodes(String value) {
if (value == null) return List.of();
Matcher matcher = CODE_PATTERN.matcher(value.toUpperCase(Locale.ROOT));
Set<String> codes = new LinkedHashSet<>();
while (matcher.find()) codes.add(matcher.group());
return List.copyOf(codes);
}
private static String regionCode(String raw) {
if (raw == null) return null;
if (raw.contains("美西")) return "WEST";
if (raw.contains("美中")) return "CENTRAL";
if (raw.contains("美东")) return "EAST";
return null;
}
private static List<ParsedSailing> sailings(String raw) {
if (raw == null || raw.isBlank()) return List.of();
LocalDate date = expectedDate(raw);
List<ParsedSailing> result = new ArrayList<>();
Matcher matcher = SAILING_PATTERN.matcher(raw);
while (matcher.find()) result.add(new ParsedSailing(matcher.group(), matcher.group(1).trim(), matcher.group(2).trim(), date));
return List.copyOf(result);
}
private static LocalDate expectedDate(String raw) {
Matcher matcher = DATE_PATTERN.matcher(raw == null ? "" : raw);
if (!matcher.find()) return null;
try {
return LocalDate.of(Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2)), Integer.parseInt(matcher.group(3)));
} catch (RuntimeException ignored) {
return null;
}
}
private static BigDecimal parsePrice(String raw) {
if (raw == null || raw.isBlank() || "/".equals(raw.trim()) || "-".equals(raw.trim())) return null;
Matcher matcher = NUMBER_PATTERN.matcher(raw.replace(",", ","));
if (!matcher.find()) return null;
try {
return new BigDecimal(matcher.group().replace(",", ""));
} catch (NumberFormatException ignored) {
return null;
}
}
private static boolean hasPrice(String value) { return parsePrice(value) != null; }
private static String clean(String value) {
if (value == null) return null;
String result = value.replace('\u00a0', ' ').trim();
return result.isBlank() ? null : result;
}
private static String firstNonBlank(String first, String second) { return clean(first) == null ? clean(second) : first; }
private static int findRowContaining(SheetData data, String text) {
for (int row = 1; row <= MAX_ROWS; row++) {
for (int col = 0; col < MAX_COLUMNS; col++) {
String value = clean(data.value(row, col));
if (value != null && value.contains(text)) return row;
}
}
return -1;
}
private static int findCellColumn(SheetData data, int row, String text) {
for (int col = 0; col < MAX_COLUMNS; col++) {
String value = clean(data.value(row, col));
if (value != null && value.contains(text)) return col;
}
return 0;
}
private static ParseIssue issue(Severity severity, String code, String message,
String sheet, Integer row, String raw) {
return new ParseIssue(severity, code, message, sheet, row, raw);
}
private record StandardTab(String sheetName, String channelCode) { }
private record ParcelChannel(String code, String name) { }
private static final class ChannelBuilder {
private final String code;
private final String name;
private final List<ParsedRate> rates = new ArrayList<>();
private String scheduleText;
private String transitText;
private String surchargeText;
private String remarkText;
private ChannelBuilder(String code, String name) { this.code = code; this.name = name; }
private ParsedChannel toChannel() {
return new ParsedChannel(code, name, "海派", name, PriceModel.REGION_WEIGHT_TIER,
List.of(), scheduleText, null, transitText, surchargeText, remarkText, rates);
}
}
private static final class SheetData {
private final Map<Integer, Map<Integer, String>> cells = new LinkedHashMap<>();
private String value(int row, int column) {
Map<Integer, String> values = cells.get(row);
return values == null ? null : values.get(column);
}
}
private static final class CellHandler implements XSSFSheetXMLHandler.SheetContentsHandler {
private final SheetData data;
private int row;
private CellHandler(SheetData data) { this.data = data; }
@Override public void startRow(int rowNum) { row = rowNum + 1; }
@Override public void endRow(int rowNum) { }
@Override public void cell(String cellReference, String formattedValue, XSSFComment comment) {
int[] coordinates = coordinates(cellReference);
if (coordinates[0] <= MAX_ROWS && coordinates[1] < MAX_COLUMNS) {
data.cells.computeIfAbsent(coordinates[0], ignored -> new LinkedHashMap<>())
.put(coordinates[1], formattedValue);
}
}
private static int[] coordinates(String reference) {
int split = 0;
while (split < reference.length() && Character.isLetter(reference.charAt(split))) split++;
int column = 0;
for (int i = 0; i < split; i++) column = column * 26 + (Character.toUpperCase(reference.charAt(i)) - 'A' + 1);
return new int[] { Integer.parseInt(reference.substring(split)), column - 1 };
}
}
}
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import com.baiyue.firstleg.mapper.CustomerDimensionConfigMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
@Repository
public class CustomerDimensionConfigRepository {
@Resource
private CustomerDimensionConfigMapper mapper;
public CustomerDimensionConfigEntity find(long tenantId) {
return mapper.selectOne(new LambdaQueryWrapper<CustomerDimensionConfigEntity>()
.eq(CustomerDimensionConfigEntity::getTenantId, tenantId));
}
public boolean insertDefault(long id, long tenantId, BigDecimal edgeAdditionCm,
String remark, String initializationKey, long operatorId) {
return mapper.insertDefault(id, tenantId, edgeAdditionCm, remark, initializationKey, operatorId) == 1;
}
public int update(CustomerDimensionConfigEntity value) {
return mapper.updateById(value);
}
public int deleteUnchangedDefault(long tenantId, long id, String initializationKey,
BigDecimal edgeAdditionCm) {
return mapper.deleteUnchangedDefault(tenantId, id, initializationKey, edgeAdditionCm);
}
}
@@ -0,0 +1,52 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.firstleg.entity.InboundCartonLabelEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.mapper.InboundCartonLabelMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class InboundCartonLabelRepository {
@Resource private InboundOrderMapper inbounds;
@Resource private InboundCartonLabelMapper labels;
public InboundOrderEntity lockInbound(long tenantId, long inboundId) {
return inbounds.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(InboundOrderEntity::getId, inboundId)
.eq(InboundOrderEntity::getDeleted, false)
.last("FOR UPDATE"));
}
public List<InboundCartonLabelEntity> findByInbound(long tenantId, long inboundId) {
return labels.selectList(new LambdaQueryWrapper<InboundCartonLabelEntity>()
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
.eq(InboundCartonLabelEntity::getInboundId, inboundId)
.orderByAsc(InboundCartonLabelEntity::getCartonIndex));
}
public InboundCartonLabelEntity findByBoxCode(long tenantId, String boxCode) {
return labels.selectOne(new LambdaQueryWrapper<InboundCartonLabelEntity>()
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
.eq(InboundCartonLabelEntity::getBoxCode, boxCode));
}
public boolean initializeOriginalPackageCount(long tenantId, long inboundId, int packageCount) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(InboundOrderEntity::getId, inboundId)
.isNull(InboundOrderEntity::getTotalPackageCount)
.set(InboundOrderEntity::getTotalPackageCount, packageCount)
.setSql("version = version + 1")) == 1;
}
public void insert(InboundCartonLabelEntity label) {
labels.insert(label);
}
}
@@ -42,6 +42,13 @@ public class InboundMeasurementRepository {
.eq(InboundOrderEntity::getId, inboundId));
}
public InboundOrderEntity lockInbound(long tenantId, long inboundId) {
return inboundOrders.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(InboundOrderEntity::getId, inboundId)
.last("FOR UPDATE"));
}
public boolean updateInbound(InboundOrderEntity inbound) {
return inboundOrders.update(inbound, new LambdaUpdateWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, inbound.getTenantId())
@@ -70,6 +77,37 @@ public class InboundMeasurementRepository {
.orderByAsc(InboundMeasurementItemEntity::getItemNo));
}
public InboundMeasurementItemEntity findItem(long tenantId, long itemId) {
return items.selectOne(new LambdaQueryWrapper<InboundMeasurementItemEntity>()
.eq(InboundMeasurementItemEntity::getTenantId, tenantId)
.eq(InboundMeasurementItemEntity::getId, itemId));
}
public InboundMeasurementItemEntity findItemByBoxCode(long tenantId, String boxCode) {
return items.selectOne(new LambdaQueryWrapper<InboundMeasurementItemEntity>()
.eq(InboundMeasurementItemEntity::getTenantId, tenantId)
.eq(InboundMeasurementItemEntity::getBoxCode, boxCode));
}
public void insertItem(InboundMeasurementItemEntity item) {
items.insert(item);
}
public boolean updateItem(InboundMeasurementItemEntity item) {
return items.update(item, new LambdaUpdateWrapper<InboundMeasurementItemEntity>()
.eq(InboundMeasurementItemEntity::getTenantId, item.getTenantId())
.eq(InboundMeasurementItemEntity::getId, item.getId())) == 1;
}
public InboundMeasurementEntity findLatestEditableMeasurement(long tenantId, long inboundId) {
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
.eq(InboundMeasurementEntity::getTenantId, tenantId)
.eq(InboundMeasurementEntity::getInboundId, inboundId)
.in(InboundMeasurementEntity::getStatus, "DRAFT", "RECEIVED", "REJECTED")
.orderByDesc(InboundMeasurementEntity::getCreatedAt)
.last("LIMIT 1"));
}
public void insertMeasurement(InboundMeasurementEntity measurement) {
measurements.insert(measurement);
}
@@ -8,6 +8,7 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundIdempotencyEntity;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
@@ -16,6 +17,7 @@ import com.baiyue.firstleg.mapper.InboundAllocationQueryMapper;
import com.baiyue.firstleg.mapper.InboundFeeMapper;
import com.baiyue.firstleg.mapper.InboundIdempotencyMapper;
import com.baiyue.firstleg.mapper.InboundMeasurementMapper;
import com.baiyue.firstleg.mapper.InboundMeasurementItemMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
@@ -30,6 +32,8 @@ public class InboundWorkflowRepository {
@Resource
private InboundMeasurementMapper measurements;
@Resource
private InboundMeasurementItemMapper measurementItems;
@Resource
private MeasureReviewRecordMapper reviews;
@Resource
private InboundStatusHistoryMapper histories;
@@ -55,6 +59,13 @@ public class InboundWorkflowRepository {
.eq(InboundMeasurementEntity::getId, measurementId));
}
public java.util.List<InboundMeasurementItemEntity> listMeasurementItems(
long tenantId, long measurementId) {
return measurementItems.selectList(new LambdaQueryWrapper<InboundMeasurementItemEntity>()
.eq(InboundMeasurementItemEntity::getTenantId, tenantId)
.eq(InboundMeasurementItemEntity::getMeasurementId, measurementId));
}
public int transitionReview(long tenantId, long inboundId, long operatorId, int expectedVersion,
boolean approved) {
return approved
@@ -0,0 +1,49 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.PriceBookQueryMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class PriceBookQueryRepository {
@Resource
private PriceBookQueryMapper mapper;
public Long activeVersionId() {
return mapper.findActiveVersionId();
}
public List<PriceBookQueryRows.ChannelRow> channels(long versionId, long originWarehouseId) {
return mapper.findChannels(versionId, originWarehouseId);
}
public List<PriceBookQueryRows.ChannelTaxModeRow> channelTaxModes(long versionId, long originWarehouseId) {
return mapper.findChannelTaxModes(versionId, originWarehouseId);
}
public List<PriceBookQueryRows.RateRow> fbaRates(long versionId, long originWarehouseId,
String channelCode, String destinationCode,
String pricingTaxMode) {
return mapper.findFbaRates(versionId, originWarehouseId, channelCode, destinationCode, pricingTaxMode);
}
public List<PriceBookQueryRows.RateRow> overseasRates(long versionId, long originWarehouseId,
String channelCode, long destinationWarehouseId,
String pricingTaxMode) {
return mapper.findOverseasRates(versionId, originWarehouseId, channelCode, destinationWarehouseId,
pricingTaxMode);
}
public List<PriceBookQueryRows.RateRow> regionTiers(long versionId, long originWarehouseId,
String channelCode, String regionCode,
String pricingTaxMode) {
return mapper.findRegionTiers(versionId, originWarehouseId, channelCode, regionCode, pricingTaxMode);
}
public boolean channelAvailable(long versionId, long originWarehouseId, String channelCode) {
return channels(versionId, originWarehouseId).stream()
.anyMatch(channel -> channelCode.equals(channel.getChannelCode()));
}
}
@@ -0,0 +1,60 @@
package com.baiyue.firstleg.repository;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
/** Read-only projections for the active price book. */
public final class PriceBookQueryRows {
private PriceBookQueryRows() {}
@Getter
@Setter
public static class ChannelRow {
private Long priceBookVersionId;
private String channelCode;
private String channelName;
private String priceModel;
private LocalDate estimatedDepartureDate;
private String estimatedDepartureText;
private String latestWarehouseEntry;
private String referenceTransit;
private String surchargeDescription;
private String remark;
}
@Getter
@Setter
public static class ChannelTaxModeRow {
private String channelCode;
private String pricingTaxMode;
}
@Getter
@Setter
public static class RateRow {
private Long id;
private Long priceBookVersionId;
private String rateCode;
private Integer rateVersion;
private Long originWarehouseId;
private String originWarehouseName;
private String channelCode;
private String priceModel;
private String destinationType;
private String destinationCode;
private Long destinationWarehouseId;
private String destinationName;
private String regionCode;
private String pricingTaxMode;
private String chargeUnit;
private BigDecimal weightFromKg;
private BigDecimal unitPrice;
private String currency;
private BigDecimal additionalFee;
private java.time.LocalDateTime effectiveFrom;
private java.time.LocalDateTime effectiveTo;
}
}
@@ -0,0 +1,142 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
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.PriceOriginAliasEntity;
import com.baiyue.firstleg.entity.PriceWarehouseMappingEntity;
import com.baiyue.firstleg.mapper.CarrierFbaRateMapper;
import com.baiyue.firstleg.mapper.PriceBookChannelMapper;
import com.baiyue.firstleg.mapper.PriceBookVersionMapper;
import com.baiyue.firstleg.mapper.PriceChannelCatalogMapper;
import com.baiyue.firstleg.mapper.PriceOriginAliasMapper;
import com.baiyue.firstleg.mapper.PriceWarehouseMappingMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public class PriceBookRepository {
@Resource private PriceBookVersionMapper versions;
@Resource private PriceBookChannelMapper channels;
@Resource private CarrierFbaRateMapper rates;
@Resource private PriceChannelCatalogMapper catalog;
@Resource private PriceOriginAliasMapper aliases;
@Resource private PriceWarehouseMappingMapper warehouseMappings;
public int insertVersion(PriceBookVersionEntity value) { return versions.insert(value); }
public int updateVersion(PriceBookVersionEntity value) {
return versions.update(value, new LambdaUpdateWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getId, value.getId()));
}
public PriceBookVersionEntity findVersion(long id) {
return versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getId, id)
.eq(PriceBookVersionEntity::getDeleted, false));
}
public List<PriceBookVersionEntity> listVersions(String status) {
return versions.selectList(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(status != null && !status.isBlank(), PriceBookVersionEntity::getStatus, status)
.eq(PriceBookVersionEntity::getDeleted, false)
.orderByDesc(PriceBookVersionEntity::getCreatedAt));
}
public PriceBookVersionEntity findActiveForUpdate() {
return versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getStatus, "ACTIVE")
.eq(PriceBookVersionEntity::getDeleted, false)
.last("FOR UPDATE"));
}
public List<PriceBookVersionEntity> listActiveForUpdate() {
return versions.selectList(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getStatus, "ACTIVE")
.eq(PriceBookVersionEntity::getDeleted, false)
.last("FOR UPDATE"));
}
public PriceBookVersionEntity findActive() {
return versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getStatus, "ACTIVE")
.eq(PriceBookVersionEntity::getDeleted, false));
}
/**
* Serializes publish operations for the platform scope. Locking one pending
* upload alone is insufficient when two different uploads are confirmed while no
* ACTIVE version exists (both transactions could otherwise pass the check).
*/
public void lockPlatformVersions() {
versions.selectList(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getDeleted, false)
.last("FOR UPDATE"));
}
public int nextVersionNo() {
PriceBookVersionEntity latest = versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.orderByDesc(PriceBookVersionEntity::getVersionNo).last("LIMIT 1 FOR UPDATE"));
return latest == null ? 1 : latest.getVersionNo() + 1;
}
public void insertChannel(PriceBookChannelEntity value) { channels.insert(value); }
public void insertRate(CarrierFbaRateEntity value) { rates.insert(value); }
public List<PriceBookChannelEntity> listChannels(long versionId) {
return channels.selectList(new LambdaQueryWrapper<PriceBookChannelEntity>()
.eq(PriceBookChannelEntity::getPriceBookVersionId, versionId)
.orderByAsc(PriceBookChannelEntity::getChannelCode));
}
public List<CarrierFbaRateEntity> listRates(long versionId) {
return rates.selectList(new LambdaQueryWrapper<CarrierFbaRateEntity>()
.eq(CarrierFbaRateEntity::getPriceBookVersionId, versionId)
.orderByAsc(CarrierFbaRateEntity::getChannelCode)
.orderByAsc(CarrierFbaRateEntity::getOriginWarehouseCode)
.orderByAsc(CarrierFbaRateEntity::getDestinationCode)
.orderByAsc(CarrierFbaRateEntity::getWeightFromKg));
}
public void deletePendingChildren(long versionId) {
rates.physicallyDeletePendingByPriceBookVersionId(versionId);
channels.physicallyDeleteByPriceBookVersionId(versionId);
}
public List<PriceChannelCatalogEntity> activeCatalog() {
return catalog.selectList(new LambdaQueryWrapper<PriceChannelCatalogEntity>()
.eq(PriceChannelCatalogEntity::getStatus, "ACTIVE").orderByAsc(PriceChannelCatalogEntity::getId));
}
public List<PriceOriginAliasEntity> activeAliases() {
return aliases.selectList(new LambdaQueryWrapper<PriceOriginAliasEntity>()
.eq(PriceOriginAliasEntity::getStatus, "ACTIVE"));
}
public List<PriceWarehouseMappingEntity> activeWarehouseMappings() {
return warehouseMappings.selectList(new LambdaQueryWrapper<PriceWarehouseMappingEntity>()
.eq(PriceWarehouseMappingEntity::getStatus, "ACTIVE")
.orderByAsc(PriceWarehouseMappingEntity::getId));
}
public void supersede(PriceBookVersionEntity old, LocalDateTime at, long operatorId) {
old.setStatus("SUPERSEDED"); old.setSupersededAt(at); old.setUpdatedBy(operatorId); old.setUpdatedAt(at);
updateVersion(old);
rates.update(null, new LambdaUpdateWrapper<CarrierFbaRateEntity>()
.eq(CarrierFbaRateEntity::getPriceBookVersionId, old.getId())
.eq(CarrierFbaRateEntity::getStatus, "ACTIVE")
.set(CarrierFbaRateEntity::getStatus, "SUPERSEDED")
.set(CarrierFbaRateEntity::getEffectiveTo, at)
.set(CarrierFbaRateEntity::getUpdatedBy, operatorId)
.set(CarrierFbaRateEntity::getUpdatedAt, at));
}
public void activate(PriceBookVersionEntity value, LocalDateTime at, long operatorId) {
value.setStatus("ACTIVE"); value.setPublishedAt(at); value.setPublishedBy(operatorId);
value.setUpdatedBy(operatorId); value.setUpdatedAt(at); updateVersion(value);
rates.update(null, new LambdaUpdateWrapper<CarrierFbaRateEntity>()
.eq(CarrierFbaRateEntity::getPriceBookVersionId, value.getId())
.set(CarrierFbaRateEntity::getStatus, "ACTIVE")
.set(CarrierFbaRateEntity::getEffectiveFrom, at)
.set(CarrierFbaRateEntity::getEffectiveTo, null)
.set(CarrierFbaRateEntity::getUpdatedBy, operatorId)
.set(CarrierFbaRateEntity::getUpdatedAt, at));
}
}
@@ -0,0 +1,12 @@
package com.baiyue.firstleg.service;
/**
* 箱号映射查询端口。标签模块负责提供实现,测量模块不解析或推导箱号业务归属。
*/
public interface CartonLabelBindingResolver {
Binding resolveByBoxCode(long tenantId, String boxCode);
record Binding(long inboundId, long inboundDetailId, String inboundCode,
int cartonIndex, int totalCartons, String boxSpecification) {
}
}
@@ -0,0 +1,378 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
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 feign.FeignException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
/** 单箱当前测量新增/覆盖与人工确认箱数编排。 */
@Service
public class CartonMeasurementService {
private static final Logger log = LoggerFactory.getLogger(CartonMeasurementService.class);
private static final Set<String> DEVICE_WRITABLE_STATUSES = Set.of("WAIT_MEASURE", "MEASURE_REJECTED");
private static final Set<String> MANUAL_COUNT_EDITABLE_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED");
private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000");
private static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
private static final String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH";
private static final long MAX_IMAGE_BYTES = 5L * 1024 * 1024;
private final InboundMeasurementRepository repository;
private final ObjectProvider<CartonLabelBindingResolver> resolverProvider;
private final MeasurementImageClient imageClient;
private final TransactionTemplate transactionTemplate;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
public CartonMeasurementService(InboundMeasurementRepository repository,
ObjectProvider<CartonLabelBindingResolver> resolverProvider,
MeasurementImageClient imageClient,
TransactionTemplate transactionTemplate) {
this.repository = repository;
this.resolverProvider = resolverProvider;
this.imageClient = imageClient;
this.transactionTemplate = transactionTemplate;
}
public CartonMeasurementResult upsert(long tenantId, long operatorId, CartonMeasurementCommand command) {
return upsert(tenantId, operatorId, null, command);
}
public CartonMeasurementResult upsertForInbound(long tenantId, long operatorId, long inboundId,
CartonMeasurementCommand command) {
if (inboundId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "入库单ID必须大于0");
}
return upsert(tenantId, operatorId, inboundId, command);
}
private CartonMeasurementResult upsert(long tenantId, long operatorId, Long expectedInboundId,
CartonMeasurementCommand command) {
validateCommand(command);
String boxCode = command.boxCode().trim();
CartonLabelBindingResolver.Binding binding = requireBinding(tenantId, boxCode);
if (expectedInboundId != null && binding.inboundId() != expectedInboundId) {
throw new BizException(ResultCode.NOT_FOUND, "箱号不属于当前入库单");
}
InboundOrderEntity inbound = requireDeviceWritableInbound(tenantId, binding.inboundId());
MeasurementImageClient.UploadResult uploaded = uploadImage(
tenantId, operatorId, inbound.getId(), command.image());
UpsertOutcome outcome;
try {
outcome = transactionTemplate.execute(status -> persist(
tenantId, operatorId, binding, boxCode, command, uploaded.fileId()));
} catch (RuntimeException exception) {
cleanupImage(tenantId, operatorId, inbound.getId(), uploaded.fileId(), "回传落库失败补偿");
throw exception;
}
if (outcome == null) {
cleanupImage(tenantId, operatorId, inbound.getId(), uploaded.fileId(), "事务未返回结果补偿");
throw new BizException(ResultCode.INTERNAL_ERROR, "单箱测量保存失败");
}
if (outcome.oldImageFileId() != null && !outcome.oldImageFileId().equals(uploaded.fileId())) {
cleanupImage(tenantId, operatorId, inbound.getId(), outcome.oldImageFileId(), "同箱复测旧图清理");
}
return outcome.result();
}
@Transactional
public CartonMeasurementResult updateConfirmedPackageCount(
long tenantId, long inboundId, long itemId, int expectedMeasurementVersion,
int confirmedPackageCount) {
if (confirmedPackageCount < 1 || confirmedPackageCount > 1_000_000) {
throw new BizException(ResultCode.VALIDATION_FAILED, "确认箱数必须是1至1000000的整数");
}
InboundOrderEntity inbound = requireManualCountEditableInbound(tenantId, inboundId);
InboundMeasurementItemEntity item = repository.findItem(tenantId, itemId);
if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "单箱测量记录不存在");
}
InboundMeasurementEntity measurement = repository.findMeasurement(
tenantId, inboundId, item.getMeasurementId());
if (measurement == null) {
throw new BizException(ResultCode.NOT_FOUND, "单箱测量记录不属于当前入库单");
}
requireVersion(expectedMeasurementVersion, measurement.getVersion());
item.setPackageCount(confirmedPackageCount);
applyCalculatedValues(item);
item.setUpdatedAt(LocalDateTime.now());
if (!repository.updateItem(item)) {
throw concurrentModification();
}
recalculateMeasurement(measurement, repository.listItems(tenantId, measurement.getId()));
if (!repository.updateMeasurement(measurement)) {
throw concurrentModification();
}
// 提交审核后仍允许平台人工修正代表箱数,但订单原始总箱数不可被测量汇总覆盖。
if ("MEASURE_PENDING".equals(inbound.getProcessStatus())) {
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
if (!repository.updateInbound(inbound)) {
throw concurrentModification();
}
}
return result(measurement, item, false);
}
private UpsertOutcome persist(long tenantId, long operatorId,
CartonLabelBindingResolver.Binding binding,
String boxCode,
CartonMeasurementCommand command, long imageFileId) {
InboundOrderEntity inbound = repository.lockInbound(tenantId, binding.inboundId());
if (inbound == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
}
if (!DEVICE_WRITABLE_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "测量已提交,设备不能新增或覆盖数据");
}
InboundMeasurementItemEntity item = repository.findItemByBoxCode(tenantId, boxCode);
boolean created = item == null;
InboundMeasurementEntity measurement;
Long oldImageFileId = null;
if (created) {
measurement = repository.findLatestEditableMeasurement(tenantId, inbound.getId());
if (measurement == null) {
measurement = newMeasurement(tenantId, operatorId, inbound.getId());
repository.insertMeasurement(measurement);
}
item = new InboundMeasurementItemEntity();
item.setId(IdWorker.getId());
item.setTenantId(tenantId);
item.setMeasurementId(measurement.getId());
item.setPackageCount(1);
item.setCreatedAt(LocalDateTime.now());
} else {
measurement = repository.findMeasurement(tenantId, inbound.getId(), item.getMeasurementId());
if (measurement == null) {
throw new BizException(ResultCode.CONFLICT, "箱号已绑定到其他入库单");
}
oldImageFileId = item.getSourceImageFileId();
}
item.setInboundDetailId(binding.inboundDetailId());
item.setExternalDetailNo(boxCode);
item.setBoxCode(boxCode);
item.setItemNo("B" + binding.cartonIndex());
item.setBoxSpecification(StringUtils.hasText(binding.boxSpecification())
? binding.boxSpecification().trim() : boxCode);
item.setLengthCm(command.lengthCm());
item.setWidthCm(command.widthCm());
item.setHeightCm(command.heightCm());
item.setUnitWeightKg(command.unitWeightKg());
item.setSourceImageFileId(imageFileId);
item.setMachineNo(command.machineNo().trim());
item.setDeviceMeasuredAt(command.measuredAt());
item.setUpdatedAt(LocalDateTime.now());
applyCalculatedValues(item);
if (created) {
repository.insertItem(item);
} else if (!repository.updateItem(item)) {
throw concurrentModification();
}
measurement.setStatus(MeasurementBatchService.STATUS_RECEIVED);
measurement.setSourceType(MeasurementBatchService.SOURCE_THIRD_PARTY);
measurement.setSourceCode("DEVICE");
measurement.setDeviceCode(command.machineNo().trim());
measurement.setMeasuredAt(command.measuredAt());
measurement.setUpdatedAt(LocalDateTime.now());
recalculateMeasurement(measurement, repository.listItems(tenantId, measurement.getId()));
if (!repository.updateMeasurement(measurement)) {
throw concurrentModification();
}
return new UpsertOutcome(result(measurement, item, created), oldImageFileId);
}
private InboundMeasurementEntity newMeasurement(long tenantId, long operatorId, long inboundId) {
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(IdWorker.getId());
measurement.setTenantId(tenantId);
measurement.setInboundId(inboundId);
measurement.setSourceType(MeasurementBatchService.SOURCE_THIRD_PARTY);
measurement.setSourceCode("DEVICE");
measurement.setStatus(MeasurementBatchService.STATUS_RECEIVED);
measurement.setCreatedBy(operatorId);
measurement.setCreatedAt(LocalDateTime.now());
measurement.setUpdatedAt(LocalDateTime.now());
measurement.setVersion(0);
return measurement;
}
private void applyCalculatedValues(InboundMeasurementItemEntity item) {
BigDecimal unitRaw = item.getLengthCm().multiply(item.getWidthCm()).multiply(item.getHeightCm())
.divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
BigDecimal unitVolume = unitRaw.setScale(4, RoundingMode.CEILING);
BigDecimal count = BigDecimal.valueOf(item.getPackageCount());
BigDecimal totalRaw = unitRaw.multiply(count).setScale(6, RoundingMode.HALF_UP);
BigDecimal totalVolume = unitVolume.multiply(count).setScale(4, RoundingMode.CEILING);
item.setGirthCm(item.getLengthCm().add(item.getWidthCm().add(item.getHeightCm())
.multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING));
item.setUnitWeightKg(item.getUnitWeightKg() == null ? null
: item.getUnitWeightKg().setScale(3, RoundingMode.CEILING));
item.setUnitVolumeCbm(unitVolume);
item.setVolumeRawCbm(totalRaw);
item.setVolumeCbm(totalVolume);
item.setTotalWeightKg(item.getUnitWeightKg() == null ? null
: item.getUnitWeightKg().multiply(count).setScale(3, RoundingMode.CEILING));
item.setTotalVolumeCbm(totalVolume);
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
item.setCalculationRuleVersion(CALCULATION_RULE_VERSION);
}
private void recalculateMeasurement(InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> items) {
long packages = items.stream().mapToLong(InboundMeasurementItemEntity::getPackageCount).sum();
if (packages > Integer.MAX_VALUE) {
throw new BizException(ResultCode.VALIDATION_FAILED, "测量确认总箱数超过系统上限");
}
BigDecimal rawVolume = items.stream().map(InboundMeasurementItemEntity::getVolumeRawCbm)
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(6, RoundingMode.HALF_UP);
BigDecimal totalVolume = items.stream().map(InboundMeasurementItemEntity::getTotalVolumeCbm)
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(4, RoundingMode.CEILING);
measurement.setTotalPackageCount((int) packages);
measurement.setTotalVolumeRawCbm(rawVolume);
measurement.setTotalVolumeCbm(totalVolume);
measurement.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR)
.setScale(3, RoundingMode.CEILING));
}
private CartonLabelBindingResolver.Binding requireBinding(long tenantId, String boxCode) {
CartonLabelBindingResolver resolver = resolverProvider.getIfAvailable();
if (resolver == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "箱号映射服务暂不可用");
}
CartonLabelBindingResolver.Binding binding = resolver.resolveByBoxCode(tenantId, boxCode);
if (binding == null) {
throw new BizException(ResultCode.NOT_FOUND, "箱号不存在或不属于当前客户");
}
return binding;
}
private InboundOrderEntity requireDeviceWritableInbound(long tenantId, long inboundId) {
InboundOrderEntity inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
}
if (!DEVICE_WRITABLE_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "测量已提交,设备不能新增或覆盖数据");
}
return inbound;
}
private InboundOrderEntity requireManualCountEditableInbound(long tenantId, long inboundId) {
InboundOrderEntity inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
}
if (!MANUAL_COUNT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许修改确认箱数");
}
return inbound;
}
private MeasurementImageClient.UploadResult uploadImage(long tenantId, long operatorId,
long inboundId, MultipartFile image) {
try {
ApiResponse<MeasurementImageClient.UploadResult> response = imageClient.upload(
internalToken, operatorId, tenantId, inboundId, image);
if (response == null || !response.successful() || response.data() == null
|| response.data().fileId() == null) {
throw downstream(response, "测量图片保存失败");
}
return response.data();
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用,测量数据未保存");
}
}
private void cleanupImage(long tenantId, long operatorId, long inboundId, long fileId, String action) {
try {
imageClient.delete(internalToken, operatorId, tenantId, inboundId, fileId);
} catch (RuntimeException exception) {
log.warn("{}失败 tenantId={}, inboundId={}, fileId={}", action, tenantId, inboundId, fileId, exception);
}
}
private BizException downstream(ApiResponse<?> response, String fallback) {
if (response == null) {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, fallback);
}
String message = StringUtils.hasText(response.message()) ? response.message() : fallback;
return new BizException(ResultCode.of(response.code()), message);
}
private void validateCommand(CartonMeasurementCommand command) {
if (command == null || !StringUtils.hasText(command.boxCode())
|| command.boxCode().trim().length() > 128) {
throw new BizException(ResultCode.VALIDATION_FAILED, "箱号不能为空且不能超过128个字符");
}
if (!positive(command.lengthCm()) || !positive(command.widthCm()) || !positive(command.heightCm())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "长、宽、高必须大于0");
}
if (command.unitWeightKg() != null && !positive(command.unitWeightKg())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "单箱重量必须大于0");
}
if (!StringUtils.hasText(command.machineNo()) || command.machineNo().trim().length() > 64) {
throw new BizException(ResultCode.VALIDATION_FAILED, "设备编号不能为空且不能超过64个字符");
}
if (command.measuredAt() == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "设备测量时间不能为空");
}
if (command.image() == null || command.image().isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "测量图片不能为空");
}
if (command.image().getSize() > MAX_IMAGE_BYTES) {
throw new BizException(ResultCode.VALIDATION_FAILED, "测量图片不能超过5MB");
}
}
private boolean positive(BigDecimal value) {
return value != null && value.compareTo(BigDecimal.ZERO) > 0;
}
private void requireVersion(int expected, Integer actual) {
if (actual == null || expected != actual) {
throw concurrentModification();
}
}
private CartonMeasurementResult result(InboundMeasurementEntity measurement,
InboundMeasurementItemEntity item, boolean created) {
return new CartonMeasurementResult(measurement.getId(), measurement.getVersion(), item.getId(),
item.getBoxCode(), item.getPackageCount(), item.getLengthCm(), item.getWidthCm(),
item.getHeightCm(), item.getUnitWeightKg(), item.getSourceImageFileId(), item.getMachineNo(),
item.getDeviceMeasuredAt(), created);
}
private BizException concurrentModification() {
return new BizException(ResultCode.CONFLICT, "测量数据已被其他人修改,请刷新后重试");
}
private record UpsertOutcome(CartonMeasurementResult result, Long oldImageFileId) {
}
}
@@ -0,0 +1,102 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.Response;
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.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
/** 管理每个客户唯一的尺寸加边配置,不参与当前订单或计费计算。 */
@Service
public class CustomerDimensionConfigService {
public static final BigDecimal DEFAULT_EDGE_ADDITION_CM = new BigDecimal("2.000");
private static final String DEFAULT_REMARK = "默认每侧加宽 2cm";
@Resource
private CustomerDimensionConfigRepository repository;
public Response get(long tenantId) {
CustomerDimensionConfigEntity value = repository.find(tenantId);
if (value == null) {
throw new BizException(ResultCode.NOT_FOUND, "客户尺寸计算配置不存在");
}
return response(value);
}
@Transactional
public Response update(long tenantId, long operatorId, UpdateRequest request) {
CustomerDimensionConfigEntity value = repository.find(tenantId);
if (value == null) {
throw new BizException(ResultCode.NOT_FOUND, "客户尺寸计算配置不存在");
}
if (!value.getVersion().equals(request.version())) {
throw new BizException(ResultCode.CONFLICT, "尺寸计算配置已被修改,请刷新后重试");
}
value.setEdgeAdditionCm(request.edgeAdditionCm());
value.setRemark(normalizeRemark(request.remark()));
value.setUpdatedBy(operatorId);
if (repository.update(value) != 1) {
throw new BizException(ResultCode.CONFLICT, "尺寸计算配置已被修改,请刷新后重试");
}
return response(value);
}
@Transactional
public InitializeResponse initializeDefault(long tenantId, long operatorId, String initializationKey) {
requireTenant(tenantId);
String normalizedKey = requireInitializationKey(initializationKey);
CustomerDimensionConfigEntity existing = repository.find(tenantId);
if (existing != null) {
return new InitializeResponse(existing.getId(),
normalizedKey.equals(existing.getInitializationKey()));
}
long id = IdWorker.getId();
boolean created = repository.insertDefault(id, tenantId, DEFAULT_EDGE_ADDITION_CM,
DEFAULT_REMARK, normalizedKey, operatorId);
CustomerDimensionConfigEntity persisted = repository.find(tenantId);
if (persisted == null) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户尺寸计算配置初始化失败");
}
boolean ownedByKey = normalizedKey.equals(persisted.getInitializationKey());
return new InitializeResponse(persisted.getId(), created || ownedByKey);
}
@Transactional
public void compensateDefault(long tenantId, String initializationKey) {
requireTenant(tenantId);
String normalizedKey = requireInitializationKey(initializationKey);
CustomerDimensionConfigEntity existing = repository.find(tenantId);
if (existing == null || !normalizedKey.equals(existing.getInitializationKey())) return;
repository.deleteUnchangedDefault(tenantId, existing.getId(), normalizedKey, DEFAULT_EDGE_ADDITION_CM);
}
private static Response response(CustomerDimensionConfigEntity value) {
return new Response(value.getId(), value.getTenantId(), value.getEdgeAdditionCm(), value.getRemark(),
value.getVersion(), value.getCreatedAt(), value.getUpdatedAt());
}
private static String normalizeRemark(String value) {
if (value == null || value.isBlank()) return null;
return value.trim();
}
private static void requireTenant(long tenantId) {
if (tenantId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "客户ID无效");
}
private static String requireInitializationKey(String value) {
if (value == null || value.isBlank() || value.trim().length() > 128) {
throw new BizException(ResultCode.BAD_REQUEST, "初始化幂等键无效");
}
return value.trim();
}
}
@@ -6,13 +6,16 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.domain.PricingDestinationType;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.entity.InboundPricingEntity;
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -36,6 +39,8 @@ public class FbaPricingService {
@Resource
private FbaPricingRepository repository;
@Resource
private ActivePriceBookEstimator priceBookEstimator;
@Resource
private Clock clock;
@Resource
private InboundFeeDetailService feeDetails;
@@ -43,53 +48,36 @@ public class FbaPricingService {
@Transactional
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
LocalDateTime requestedAt, String idempotencyKey) {
LocalDateTime pricingAt = requestedAt == null ? LocalDateTime.now(clock) : requestedAt;
String hash = hash("CALCULATE", inboundId, requestedAt == null ? "CURRENT" : requestedAt.toString());
InboundPricingIdempotencyEntity token = beginIdempotency(
tenantId, operatorId, inboundId, "FEE_CALCULATE", idempotencyKey, hash);
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null);
InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId);
List<CustomerChargeRuleEntity> matchedRules = repository.matchRules(inbound, pricingAt);
CustomerChargeRuleEntity rule = unique(matchedRules, "客户计费规则");
List<CarrierFbaRateEntity> matchedRates = repository.matchRates(inbound, rule.getChargeMode(), pricingAt);
CarrierFbaRateEntity rate = unique(matchedRates, "船司 FBA 价格");
if (!rule.getCurrency().equals(rate.getCurrency())) {
throw conflict("客户规则币种与船司价格币种不一致");
}
RoundingMode roundingMode;
try {
roundingMode = RoundingMode.valueOf(rule.getRoundingMode());
} catch (IllegalArgumentException exception) {
throw conflict("客户规则舍入方式无效: " + rule.getRoundingMode());
}
int scale = rule.getRoundingScale();
if (scale < 0 || scale > 4 || rule.getConversionFactor().signum() <= 0) {
throw conflict("客户规则换算系数或精度无效");
}
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest(
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
pricingTaxMode(inbound), inbound.getActualGrossWeightKg(), inbound.getActualVolumeCbm()));
RateRow rate = estimate.rate();
BigDecimal weight = inbound.getActualGrossWeightKg().setScale(3, RoundingMode.HALF_UP);
BigDecimal volume = inbound.getActualVolumeCbm().setScale(4, RoundingMode.HALF_UP);
BigDecimal converted = "KG".equals(rule.getChargeMode())
? volume.multiply(rule.getConversionFactor()).setScale(scale, roundingMode)
: weight.divide(rule.getConversionFactor(), scale, roundingMode);
BigDecimal actual = "KG".equals(rule.getChargeMode()) ? weight : volume;
BigDecimal chargeable = actual.max(converted).setScale(scale, roundingMode);
BigDecimal subtotal = chargeable.multiply(rate.getUnitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
BigDecimal additional = rate.getAdditionalFee().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
BigDecimal chargeable = estimate.chargeableQuantity();
BigDecimal converted = chargeable;
BigDecimal subtotal = estimate.amount().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
BigDecimal additional = rate.getAdditionalFee() == null ? BigDecimal.ZERO
: rate.getAdditionalFee().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
BigDecimal total = subtotal.add(additional).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
LocalDateTime now = LocalDateTime.now(clock);
int version = repository.nextFeeVersion(tenantId, inboundId);
InboundFeeEntity fee = snapshot(tenantId, operatorId, inbound, rule, rate, version,
InboundFeeEntity fee = snapshot(tenantId, operatorId, inbound, estimate, rate, version,
weight, volume, converted, chargeable, subtotal, additional, total, now);
repository.supersedeCalculated(tenantId, inboundId);
repository.insertFee(fee);
repository.insertItem(baseItem(tenantId, fee, rate, chargeable, subtotal, now));
if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, fee, additional, now));
if (repository.markInboundCalculated(tenantId, inboundId, inbound.getProcessStatus(),
rule.getChargeMode(), chargeable) != 1) {
estimate.chargeUnit(), chargeable) != 1) {
throw conflict("入库单状态已变化,费用试算未保存");
}
complete(token, fee.getId(), now);
@@ -245,24 +233,36 @@ public class FbaPricingService {
private InboundPricingEntity requireApprovedInbound(long tenantId, long inboundId) {
InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
if (!"FBA".equals(inbound.getDestinationType())) throw conflict("二期仅支持 FBA 目的仓计费");
if (!"FBA".equals(inbound.getDestinationType()) && !"OVERSEAS".equals(inbound.getDestinationType())) {
throw conflict("当前目的地类型不支持自动计费");
}
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) throw conflict("只能消费审核通过的测量快照");
if (inbound.getActualGrossWeightKg() == null || inbound.getActualGrossWeightKg().signum() <= 0
|| inbound.getActualVolumeCbm() == null || inbound.getActualVolumeCbm().signum() <= 0) {
throw conflict("审核通过的测量快照必须包含大于零的实重和体积");
}
if (inbound.getCustomerId() == null || inbound.getWarehouseId() == null
|| inbound.getCarrierId() == null || !StringUtils.hasText(inbound.getChannelCode())
|| !StringUtils.hasText(inbound.getDestinationCode())) {
throw conflict("入库单缺少客户、起运仓、渠道、船司或 FBA 仓匹配维度");
|| !StringUtils.hasText(inbound.getChannelCode())) {
throw conflict("入库单缺少客户、起运仓或渠道匹配维度");
}
return inbound;
}
private <T> T unique(List<T> values, String name) {
if (values.isEmpty()) throw conflict("未匹配到有效" + name);
if (values.size() > 1) throw conflict("匹配到多条有效" + name + ",请修正有效期配置");
return values.get(0);
private static PricingDestinationType destinationType(InboundPricingEntity inbound) {
try {
return PricingDestinationType.valueOf(inbound.getDestinationType());
} catch (RuntimeException exception) {
throw conflict("入库单目的地类型配置无效");
}
}
private static PricingTaxMode pricingTaxMode(InboundPricingEntity inbound) {
if (!StringUtils.hasText(inbound.getPricingTaxMode())) return null;
try {
return PricingTaxMode.valueOf(inbound.getPricingTaxMode());
} catch (RuntimeException exception) {
throw conflict("入库单报价包税维度配置无效");
}
}
private InboundPricingIdempotencyEntity beginIdempotency(long tenantId, long operatorId,
@@ -296,34 +296,37 @@ public class FbaPricingService {
}
private InboundFeeEntity snapshot(long tenantId, long operatorId, InboundPricingEntity inbound,
CustomerChargeRuleEntity rule, CarrierFbaRateEntity rate, int version,
ActivePriceBookEstimator.Estimate estimate, RateRow rate, int version,
BigDecimal weight, BigDecimal volume, BigDecimal converted,
BigDecimal chargeable, BigDecimal subtotal, BigDecimal additional,
BigDecimal total, LocalDateTime now) {
InboundFeeEntity fee = new InboundFeeEntity();
fee.setTenantId(tenantId); fee.setInboundId(inbound.getId());
fee.setFeeNo("FBAF-" + inbound.getId() + "-" + version); fee.setFeeVersion(version);
fee.setChargeRuleId(rule.getId()); fee.setChargeRuleVersion(rule.getRuleVersion());
fee.setChargeRuleId(null); fee.setChargeRuleVersion(null);
fee.setCarrierRateId(rate.getId()); fee.setCarrierRateVersion(rate.getRateVersion());
fee.setRuleCode(rule.getRuleCode()); fee.setCustomerId(inbound.getCustomerId());
fee.setPriceBookVersionId(estimate.priceBookVersionId());
fee.setRuleCode(null); fee.setCustomerId(inbound.getCustomerId());
fee.setCustomerName(inbound.getCustomerName()); fee.setBusinessType(inbound.getBusinessType());
fee.setTaxMode(inbound.getTaxMode()); fee.setRoundingScale(rule.getRoundingScale());
fee.setRoundingMode(rule.getRoundingMode()); fee.setRuleEffectiveFrom(rule.getEffectiveFrom());
fee.setRuleEffectiveTo(rule.getEffectiveTo()); fee.setRateCode(rate.getRateCode());
fee.setTaxMode(inbound.getTaxMode()); fee.setPricingTaxMode(inbound.getPricingTaxMode());
fee.setRoundingScale(null); fee.setRoundingMode(null); fee.setRuleEffectiveFrom(null);
fee.setRuleEffectiveTo(null); fee.setRateCode(rate.getRateCode());
fee.setOriginWarehouseId(rate.getOriginWarehouseId()); fee.setOriginWarehouseName(rate.getOriginWarehouseName());
fee.setChannelCode(rate.getChannelCode()); fee.setCarrierId(rate.getCarrierId());
fee.setCarrierName(rate.getCarrierName()); fee.setDestinationCode(rate.getDestinationCode());
fee.setChannelCode(rate.getChannelCode()); fee.setCarrierId(null);
fee.setCarrierName(null); fee.setDestinationType(inbound.getDestinationType());
fee.setDestinationWarehouseId(inbound.getDestinationWarehouseId()); fee.setDestinationName(inbound.getDestinationName());
fee.setDestinationCode(inbound.getDestinationCode());
fee.setRateEffectiveFrom(rate.getEffectiveFrom()); fee.setRateEffectiveTo(rate.getEffectiveTo());
fee.setChargeMode(rule.getChargeMode()); fee.setConversionFactor(rule.getConversionFactor());
fee.setChargeMode(estimate.chargeUnit()); fee.setConversionFactor(null);
fee.setActualWeightKg(weight); fee.setActualVolumeCbm(volume); fee.setConvertedQuantity(converted);
fee.setChargeableQuantity(chargeable); fee.setChargeUnit(rule.getChargeMode()); fee.setCurrency(rate.getCurrency());
fee.setChargeableQuantity(chargeable); fee.setChargeUnit(estimate.chargeUnit()); fee.setCurrency(rate.getCurrency());
fee.setSubtotalAmount(subtotal); fee.setAdditionalAmount(additional); fee.setTotalAmount(total);
fee.setFeeStatus("CALCULATED"); fee.setReviewStatus("NOT_SUBMITTED"); fee.setExceptionRelease(false);
fee.setCalculatedBy(operatorId); fee.setCalculatedAt(now); fee.setCreatedAt(now); fee.setUpdatedAt(now);
return fee;
}
private InboundFeeItemEntity baseItem(long tenantId, InboundFeeEntity fee, CarrierFbaRateEntity rate,
private InboundFeeItemEntity baseItem(long tenantId, InboundFeeEntity fee, RateRow rate,
BigDecimal quantity, BigDecimal amount, LocalDateTime now) {
InboundFeeItemEntity item = item(tenantId, fee, 1, "FBA_BASE", "FBA基础运费", amount, now);
item.setQuantity(quantity); item.setUnit(fee.getChargeUnit()); item.setUnitPrice(rate.getUnitPrice());
@@ -360,10 +363,12 @@ public class FbaPricingService {
return new FeeSnapshotResponse(fee.getId(), fee.getFeeNo(), fee.getFeeVersion(), fee.getFeeStatus(),
fee.getInboundId(), inbound == null ? null : inbound.getInboundCode(),
fee.getChargeRuleId(), fee.getRuleCode(), fee.getChargeRuleVersion(),
fee.getCarrierRateId(), fee.getRateCode(), fee.getCarrierRateVersion(),
fee.getCarrierRateId(), fee.getRateCode(), fee.getCarrierRateVersion(), fee.getPriceBookVersionId(),
fee.getCustomerId(), fee.getCustomerName(), fee.getOriginWarehouseId(), fee.getOriginWarehouseName(),
fee.getBusinessType(), fee.getChannelCode(), fee.getCarrierId(), fee.getCarrierName(),
fee.getDestinationCode(), fee.getTaxMode(), fee.getChargeMode(), fee.getConversionFactor(),
fee.getDestinationType(), fee.getDestinationWarehouseId(), fee.getDestinationName(), fee.getDestinationCode(),
fee.getTaxMode(), fee.getPricingTaxMode(), fee.getChargeMode(),
fee.getConversionFactor(),
fee.getRoundingScale(), fee.getRoundingMode(), fee.getActualWeightKg(), fee.getActualVolumeCbm(),
fee.getConvertedQuantity(), fee.getChargeableQuantity(), fee.getChargeUnit(), unitPrice,
fee.getSubtotalAmount(), fee.getAdditionalAmount(), fee.getTotalAmount(), fee.getCurrency(),
@@ -0,0 +1,91 @@
package com.baiyue.firstleg.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.firstleg.client.PrintPdfClient;
import com.baiyue.firstleg.dto.InboundCartonLabels;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.util.List;
/** Allocates stable carton codes and forwards immutable label facts to Print. */
@Service
public class InboundCartonLabelPrintService {
private static final BigDecimal PAGE_MM = new BigDecimal("100");
private static final List<String> SAFE_RESPONSE_HEADERS = List.of(
HttpHeaders.CONTENT_DISPOSITION, HttpHeaders.CACHE_CONTROL, HttpHeaders.PRAGMA,
"X-Print-Page-Count", "X-Request-Id", "X-Trace-Id");
private final InboundCartonLabelService labelService;
private final PrintPdfClient printClient;
private final ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
public InboundCartonLabelPrintService(InboundCartonLabelService labelService,
PrintPdfClient printClient,
ObjectMapper objectMapper) {
this.labelService = labelService;
this.printClient = printClient;
this.objectMapper = objectMapper;
}
public ResponseEntity<byte[]> generate(long tenantId, long inboundId,
List<Integer> cartonIndexes, String requestId) {
InboundCartonLabels.Batch batch = labelService.list(tenantId, inboundId, cartonIndexes);
PrintPdfClient.InboundMarkPdfRequest request = new PrintPdfClient.InboundMarkPdfRequest(
"INBOUND_MARK_V1", new PrintPdfClient.PageSpec(PAGE_MM, PAGE_MM),
batch.inboundCode() + "-" + batch.totalCount() + "件-箱唛.pdf",
batch.labels().stream().map(this::toPrintLabel).toList());
try {
return requirePdf(printClient.generateInboundMarks(internalToken, requestId, request));
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试");
}
}
private PrintPdfClient.Label toPrintLabel(InboundCartonLabels.Label label) {
return new PrintPdfClient.Label(label.topCode(), label.warehouseText(), label.cartonText(),
label.routeText(), label.boxCode());
}
private ResponseEntity<byte[]> requirePdf(ResponseEntity<byte[]> response) {
byte[] body = response == null || response.getBody() == null ? new byte[0] : response.getBody();
MediaType contentType = response == null ? null : response.getHeaders().getContentType();
if (contentType == null || !MediaType.APPLICATION_PDF.isCompatibleWith(contentType) || body.length == 0) {
throw downstreamError(body);
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
SAFE_RESPONSE_HEADERS.forEach(name -> {
String value = response.getHeaders().getFirst(name);
if (StringUtils.hasText(value)) headers.set(name, value);
});
headers.setContentLength(body.length);
return ResponseEntity.ok().headers(headers).body(body);
}
private BizException downstreamError(byte[] body) {
try {
ApiResponse<Object> error = objectMapper.readValue(body, new TypeReference<>() { });
if (error != null && !error.successful()) {
return new BizException(ResultCode.of(error.code()),
StringUtils.hasText(error.message()) ? error.message() : "打印服务生成失败");
}
} catch (Exception ignored) {
// Non-contract responses are treated as unavailable instead of exposing downstream content.
}
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务返回了无效响应");
}
}
@@ -0,0 +1,243 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.dto.InboundCartonLabels;
import com.baiyue.firstleg.entity.InboundCartonLabelEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundCartonLabelRepository;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/** 首次生成后不可变的箱号分配;重打和测量解析只读取该映射。 */
@Service
public class InboundCartonLabelService implements CartonLabelBindingResolver {
private static final int MAX_LABELS = 2000;
private static final int MAX_QR_CODE_POINTS = 34;
private static final Set<String> ALLOCATABLE_STATUSES = Set.of("WAIT_MEASURE", "MEASURING");
@Resource private InboundCartonLabelRepository repository;
@Resource private OrderCargoClient orderCargoClient;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@Transactional
public InboundCartonLabels.Batch getOrCreate(long tenantId, long inboundId) {
requireIds(tenantId, inboundId);
InboundOrderEntity inbound = repository.lockInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
List<InboundCartonLabelEntity> existing = new ArrayList<>(repository.findByInbound(tenantId, inboundId));
if (!existing.isEmpty()) {
List<InboundCartonLabelEntity> consistent = requireConsistent(existing);
freezeOriginalPackageCount(inbound, consistent.get(0).getTotalCartonCount());
return batch(inbound, consistent);
}
if (!ALLOCATABLE_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许首次生成箱号");
}
List<OrderCargoClient.CargoLabelBinding> cargoes = loadCargoes(inbound);
int total = cargoes.stream().mapToInt(this::requirePackageCount).sum();
if (total < 1 || total > MAX_LABELS) {
throw new BizException(ResultCode.VALIDATION_FAILED, "唛头箱数必须为1至2000箱");
}
freezeOriginalPackageCount(inbound, total);
requirePrintableInboundCode(inbound.getInboundCode(), total);
int cartonIndex = 0;
LocalDateTime now = LocalDateTime.now();
for (OrderCargoClient.CargoLabelBinding cargo : cargoes) {
int detailCount = requirePackageCount(cargo);
for (int offset = 0; offset < detailCount; offset++) {
cartonIndex++;
InboundCartonLabelEntity label = label(inbound, cargo, total, cartonIndex, now);
repository.insert(label);
existing.add(label);
}
}
return batch(inbound, List.copyOf(existing));
}
@Transactional
public InboundCartonLabels.Batch list(long tenantId, long inboundId, List<Integer> cartonIndexes) {
InboundCartonLabels.Batch batch = getOrCreate(tenantId, inboundId);
if (cartonIndexes == null || cartonIndexes.isEmpty()) return batch;
Set<Integer> selected = new HashSet<>(cartonIndexes);
if (selected.stream().anyMatch(index -> index == null || index < 1 || index > batch.totalCount())) {
throw new BizException(ResultCode.BAD_REQUEST, "补打箱下标超出有效范围");
}
List<InboundCartonLabels.Label> labels = batch.labels().stream()
.filter(label -> selected.contains(label.cartonIndex()))
.toList();
return new InboundCartonLabels.Batch(batch.inboundId(), batch.orderId(), batch.inboundCode(),
batch.totalCount(), labels);
}
@Override
public CartonLabelBindingResolver.Binding resolveByBoxCode(long tenantId, String boxCode) {
InboundCartonLabelEntity label = requireByBoxCode(tenantId, boxCode);
return new CartonLabelBindingResolver.Binding(label.getInboundId(), label.getInboundDetailId(),
label.getInboundCode(), label.getCartonIndex(), label.getTotalCartonCount(),
label.getBoxSpecification());
}
public InboundCartonLabels.Resolution resolveDetailsByBoxCode(long tenantId, String boxCode) {
InboundCartonLabelEntity label = requireByBoxCode(tenantId, boxCode);
return new InboundCartonLabels.Resolution(label.getInboundId(), label.getOrderId(),
label.getInboundDetailId(), label.getBoxCode(), label.getTotalCartonCount(),
label.getCartonIndex(), label.getCargoItemNo(), label.getCargoName(),
label.getBoxSpecification());
}
private InboundCartonLabelEntity requireByBoxCode(long tenantId, String boxCode) {
if (tenantId <= 0 || !StringUtils.hasText(boxCode)) {
throw new BizException(ResultCode.BAD_REQUEST, "客户ID和箱号不能为空");
}
InboundCartonLabelEntity label = repository.findByBoxCode(tenantId, boxCode.trim());
if (label == null) throw new BizException(ResultCode.NOT_FOUND, "箱号不存在");
return label;
}
private List<OrderCargoClient.CargoLabelBinding> loadCargoes(InboundOrderEntity inbound) {
try {
ApiResponse<List<OrderCargoClient.CargoLabelBinding>> response = orderCargoClient.listForCartonLabels(
internalToken, inbound.getTenantId(), inbound.getOrderId());
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单货物明细查询失败");
}
if (response.data().isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "订单没有可生成唛头的货物明细");
}
return response.data().stream()
.sorted(java.util.Comparator.comparing(OrderCargoClient.CargoLabelBinding::itemNo,
java.util.Comparator.nullsLast(Integer::compareTo))
.thenComparing(OrderCargoClient.CargoLabelBinding::cargoId,
java.util.Comparator.nullsLast(Long::compareTo)))
.toList();
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法生成唛头");
}
}
private int requirePackageCount(OrderCargoClient.CargoLabelBinding cargo) {
if (cargo.cargoId() == null || cargo.cargoId() <= 0 || cargo.itemNo() == null
|| cargo.itemNo() < 1 || cargo.packageCount() == null || cargo.packageCount() < 1) {
throw new BizException(ResultCode.VALIDATION_FAILED, "订单货物明细缺少有效箱数或序号");
}
return cargo.packageCount();
}
private void requirePrintableInboundCode(String inboundCode, int total) {
if (!StringUtils.hasText(inboundCode) || !inboundCode.matches("[A-Za-z0-9._:-]+")) {
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码不能生成可打印二维码");
}
String longest = inboundCode + "#" + total + "-" + total;
if (longest.codePointCount(0, longest.length()) > MAX_QR_CODE_POINTS) {
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码和箱号组合超过二维码长度限制");
}
}
private void freezeOriginalPackageCount(InboundOrderEntity inbound, int total) {
Integer original = inbound.getTotalPackageCount();
if (original != null && original != total) {
throw new BizException(ResultCode.CONFLICT, "订单总箱数与已冻结箱号数量不一致");
}
if (original == null) {
if (!repository.initializeOriginalPackageCount(inbound.getTenantId(), inbound.getId(), total)) {
throw new BizException(ResultCode.CONFLICT, "订单总箱数已变化,请刷新后重试");
}
inbound.setTotalPackageCount(total);
inbound.setVersion(inbound.getVersion() == null ? 1 : inbound.getVersion() + 1);
}
}
private InboundCartonLabelEntity label(InboundOrderEntity inbound,
OrderCargoClient.CargoLabelBinding cargo,
int total, int index, LocalDateTime now) {
InboundCartonLabelEntity label = new InboundCartonLabelEntity();
label.setId(IdWorker.getId());
label.setTenantId(inbound.getTenantId());
label.setInboundId(inbound.getId());
label.setOrderId(inbound.getOrderId());
label.setInboundDetailId(cargo.cargoId());
label.setCargoItemNo(cargo.itemNo());
label.setCargoName(cargo.cargoName());
label.setDetailPackageCount(cargo.packageCount());
label.setInboundCode(inbound.getInboundCode());
label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index);
label.setTotalCartonCount(total);
label.setCartonIndex(index);
label.setLengthCm(cargo.lengthCm());
label.setWidthCm(cargo.widthCm());
label.setHeightCm(cargo.heightCm());
label.setBoxSpecification(specification(cargo));
label.setCreatedAt(now);
return label;
}
private String specification(OrderCargoClient.CargoLabelBinding cargo) {
if (cargo.lengthCm() == null || cargo.widthCm() == null || cargo.heightCm() == null) {
return StringUtils.hasText(cargo.cargoName()) ? cargo.cargoName().trim() : "货物明细" + cargo.itemNo();
}
return decimal(cargo.lengthCm()) + "x" + decimal(cargo.widthCm()) + "x"
+ decimal(cargo.heightCm()) + "cm";
}
private String decimal(BigDecimal value) {
return value.stripTrailingZeros().toPlainString();
}
private List<InboundCartonLabelEntity> requireConsistent(List<InboundCartonLabelEntity> labels) {
int total = labels.get(0).getTotalCartonCount();
if (total != labels.size()) throw corrupt();
for (int index = 0; index < labels.size(); index++) {
InboundCartonLabelEntity label = labels.get(index);
if (label.getTotalCartonCount() != total || label.getCartonIndex() != index + 1) throw corrupt();
}
return labels;
}
private BizException corrupt() {
return new BizException(ResultCode.INTERNAL_ERROR, "入库单箱号映射不完整,请联系管理员");
}
private InboundCartonLabels.Batch batch(InboundOrderEntity inbound,
List<InboundCartonLabelEntity> entities) {
List<InboundCartonLabels.Label> labels = entities.stream().map(entity ->
new InboundCartonLabels.Label(entity.getId(), entity.getInboundDetailId(), entity.getCargoItemNo(),
entity.getCargoName(), entity.getDetailPackageCount(), entity.getBoxCode(),
entity.getTotalCartonCount(), entity.getCartonIndex(), entity.getBoxSpecification(),
entity.getLengthCm(), entity.getWidthCm(), entity.getHeightCm(), inbound.getInboundCode(),
fallback(inbound.getWarehouseName(), "WAREHOUSE"),
"CTNS:" + entity.getTotalCartonCount() + "-" + entity.getCartonIndex(),
fallback(inbound.getChannelName(), fallback(inbound.getChannelCode(), "ROUTE"))))
.toList();
int total = entities.isEmpty() ? 0 : entities.get(0).getTotalCartonCount();
return new InboundCartonLabels.Batch(inbound.getId(), inbound.getOrderId(), inbound.getInboundCode(),
total, labels);
}
private String fallback(String value, String fallback) {
return StringUtils.hasText(value) ? value.trim() : fallback;
}
private void requireIds(long tenantId, long inboundId) {
if (tenantId <= 0 || inboundId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "客户ID和入库单ID必须大于0");
}
}
}
@@ -101,6 +101,7 @@ public class InboundMeasurementService {
BigDecimal confirmedWeight = confirmedWeightForSubmission(weights);
if (!alreadyAdvanced) {
requireConfirmedPackageTotal(inbound, items);
measurement.setStatus(STATUS_SUBMITTED);
if (!repository.updateMeasurement(measurement)) {
throw concurrentModification();
@@ -150,7 +151,7 @@ public class InboundMeasurementService {
weight.setOcrRawText(request.ocrRawText());
weight.setOcrWeightValue(request.ocrWeightValue());
weight.setOcrWeightUnit(unit);
weight.setConfidence(request.confidence());
weight.setConfidence(normalizeConfidence(request.confidence()));
weight.setRecognitionStatus(isPositive(request.ocrWeightValue()) && isSupportedUnit(unit)
? WEIGHT_PENDING_CONFIRM : WEIGHT_NEEDS_MANUAL);
weight.setCreatedBy(operatorId);
@@ -168,13 +169,22 @@ public class InboundMeasurementService {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(request.version(), weight.getVersion());
String adjustedUnit = normalizeUnit(request.adjustedWeightUnit());
applyWeightCorrection(weight, request.version(), request.adjustedWeightValue(),
request.adjustedWeightUnit(), request.adjustmentReason(), operatorId, operatorName);
return toWeight(weight);
}
private int applyWeightCorrection(
InboundWeightResultEntity weight, int expectedVersion, BigDecimal adjustedWeightValue,
String adjustedWeightUnit, String adjustmentReason, long operatorId, String operatorName) {
requireCompleteAdjustment(adjustedWeightValue, adjustedWeightUnit, adjustmentReason);
String adjustedUnit = normalizeUnit(adjustedWeightUnit);
BigDecimal beforeValue = effectiveValue(weight);
String beforeUnit = effectiveUnit(weight);
weight.setAdjustedWeightValue(request.adjustedWeightValue());
weight.setAdjustedWeightValue(adjustedWeightValue);
weight.setAdjustedWeightUnit(adjustedUnit);
weight.setAdjustmentReason(request.adjustmentReason());
weight.setAdjustmentReason(adjustmentReason.trim());
weight.setAdjustedBy(operatorId);
weight.setAdjustedAt(LocalDateTime.now());
weight.setFinalWeightKg(null);
@@ -185,10 +195,12 @@ public class InboundMeasurementService {
if (!repository.updateWeight(weight)) {
throw concurrentModification();
}
insertRevision(weight, request.version() + 1, "CORRECT", beforeValue, beforeUnit,
request.adjustedWeightValue(), adjustedUnit, null, request.adjustmentReason(),
int correctedVersion = expectedVersion + 1;
weight.setVersion(correctedVersion);
insertRevision(weight, correctedVersion, "CORRECT", beforeValue, beforeUnit,
adjustedWeightValue, adjustedUnit, null, adjustmentReason.trim(),
operatorId, operatorName);
return toWeight(weight);
return correctedVersion;
}
@Transactional
@@ -198,7 +210,15 @@ public class InboundMeasurementService {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(request.version(), weight.getVersion());
if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) {
int currentVersion = request.version();
if (request.hasAdjustment()) {
requireCompleteAdjustment(request.adjustedWeightValue(), request.adjustedWeightUnit(),
request.adjustmentReason());
requireConfirmableKg(request.adjustedWeightValue(), request.adjustedWeightUnit());
currentVersion = applyWeightCorrection(weight, currentVersion,
request.adjustedWeightValue(), request.adjustedWeightUnit(),
request.adjustmentReason(), operatorId, operatorName);
} else if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) {
return toWeight(weight);
}
BigDecimal value = effectiveValue(weight);
@@ -207,7 +227,7 @@ public class InboundMeasurementService {
throw new BizException(ResultCode.VALIDATION_FAILED, "OCR 重量无法确认,请先人工修正数值和单位");
}
String unit = normalizeUnit(rawUnit);
BigDecimal finalKg = toKg(value, unit);
BigDecimal finalKg = requireConfirmableKg(value, unit);
weight.setFinalWeightKg(finalKg);
weight.setRecognitionStatus(WEIGHT_CONFIRMED);
weight.setConfirmedBy(operatorId);
@@ -216,7 +236,9 @@ public class InboundMeasurementService {
if (!repository.updateWeight(weight)) {
throw concurrentModification();
}
insertRevision(weight, request.version() + 1, "CONFIRM", value, unit, value, unit,
int confirmedVersion = currentVersion + 1;
weight.setVersion(confirmedVersion);
insertRevision(weight, confirmedVersion, "CONFIRM", value, unit, value, unit,
finalKg, null, operatorId, operatorName);
recalculateConfirmedWeight(inbound, operatorId);
return toWeight(weight);
@@ -247,6 +269,13 @@ public class InboundMeasurementService {
return isPositive(total) ? total : null;
}
private void requireCompleteAdjustment(BigDecimal value, String unit, String reason) {
if (!isPositive(value) || !StringUtils.hasText(unit) || !StringUtils.hasText(reason)) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"人工修改重量时必须同时填写有效重量、单位和修改原因");
}
}
private void insertRevision(InboundWeightResultEntity weight, int revisionNo, String action,
BigDecimal beforeValue, String beforeUnit,
BigDecimal afterValue, String afterUnit, BigDecimal finalWeightKg,
@@ -285,6 +314,7 @@ public class InboundMeasurementService {
measurement == null ? null : measurement.getOriginalDocumentNo(),
measurement == null ? null : measurement.getOriginalGrossWeightKg(),
measurement == null ? null : measurement.getOriginalVolumeCbm(),
inbound.getTotalPackageCount(),
measurement == null ? inbound.getTotalPackageCount() : measurement.getTotalPackageCount(),
inbound.getActualGrossWeightKg(),
measurement == null ? inbound.getActualVolumeCbm() : measurement.getTotalVolumeCbm(),
@@ -300,7 +330,25 @@ public class InboundMeasurementService {
item.getUnitWeightKg(), item.getUnitVolumeCbm(), item.getTotalWeightKg(),
item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(),
item.getOverLimitWarning(), item.getOuterLengthCm(), item.getOuterWidthCm(),
item.getOuterHeightCm(), item.getRemark());
item.getOuterHeightCm(), item.getRemark(), item.getBoxCode(), item.getPackageCount(),
item.getMachineNo(), item.getDeviceMeasuredAt(), item.getUpdatedAt());
}
private void requireConfirmedPackageTotal(InboundOrderEntity inbound,
List<InboundMeasurementItemEntity> items) {
if (inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() < 1) {
throw new BizException(ResultCode.VALIDATION_FAILED, "入库单原始总箱数无效,不能提交测量");
}
long confirmed = items.stream()
.map(InboundMeasurementItemEntity::getPackageCount)
.filter(java.util.Objects::nonNull)
.mapToLong(Integer::longValue)
.sum();
if (confirmed != inbound.getTotalPackageCount()) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"人工确认箱数合计" + confirmed + "箱,与入库单总箱数"
+ inbound.getTotalPackageCount() + "箱不一致");
}
}
private InboundMeasurementResponses.WeightResult toWeight(InboundWeightResultEntity weight) {
@@ -370,6 +418,17 @@ public class InboundMeasurementService {
return unit.trim().toUpperCase(Locale.ROOT);
}
private BigDecimal normalizeConfidence(BigDecimal confidence) {
if (confidence == null) {
return null;
}
if (confidence.compareTo(BigDecimal.ZERO) < 0
|| confidence.compareTo(BigDecimal.ONE) > 0) {
throw new BizException(ResultCode.VALIDATION_FAILED, "OCR 置信度必须在 0 到 1 之间");
}
return confidence.setScale(5, RoundingMode.HALF_UP);
}
private boolean isSupportedUnit(String unit) {
if (!StringUtils.hasText(unit)) {
return false;
@@ -396,6 +455,15 @@ public class InboundMeasurementService {
return kg.setScale(3, RoundingMode.HALF_UP);
}
private BigDecimal requireConfirmableKg(BigDecimal value, String unit) {
BigDecimal finalKg = toKg(value, normalizeUnit(unit));
if (!isPositive(finalKg)) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"换算后重量不能小于 0.001 KG");
}
return finalKg;
}
private BigDecimal effectiveValue(InboundWeightResultEntity weight) {
return weight.getAdjustedWeightValue() == null
? weight.getOcrWeightValue() : weight.getAdjustedWeightValue();
@@ -3,12 +3,15 @@ package com.baiyue.firstleg.service;
import jakarta.annotation.Resource;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
import com.baiyue.firstleg.dto.InboundOrderReference;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -25,11 +28,9 @@ public class InboundOrderCreationService {
@Transactional
public long createFromApprovedOrder(OrderApprovedInboundCommand command) {
InboundOrderEntity existing = orders.selectOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, command.tenantId())
.eq(InboundOrderEntity::getOrderId, command.orderId()));
InboundOrderEntity existing = findByOrder(command.tenantId(), command.orderId(), false);
if (existing != null) {
return existing.getId();
return requireMatchingInboundCode(existing, command.inboundCode());
}
LocalDateTime now = LocalDateTime.now();
@@ -58,6 +59,7 @@ public class InboundOrderCreationService {
inbound.setCarrierId(command.carrierId());
inbound.setCarrierName(command.carrierName());
inbound.setTaxMode(command.taxMode());
inbound.setPricingTaxMode(command.pricingTaxMode());
inbound.setContainerMode(command.containerMode());
inbound.setCustomsInspection(command.customsInspection());
inbound.setDestinationType(command.destinationType());
@@ -73,7 +75,15 @@ public class InboundOrderCreationService {
inbound.setUpdatedAt(now);
inbound.setDeleted(false);
inbound.setVersion(0);
orders.insert(inbound);
try {
orders.insert(inbound);
} catch (DuplicateKeyException duplicate) {
InboundOrderEntity winner = findByOrder(command.tenantId(), command.orderId(), true);
if (winner == null) {
throw new BizException(ResultCode.CONFLICT, "入仓码已被其他订单使用");
}
return requireMatchingInboundCode(winner, command.inboundCode());
}
InboundStatusHistoryEntity history = new InboundStatusHistoryEntity();
history.setId(IdWorker.getId());
@@ -88,6 +98,23 @@ public class InboundOrderCreationService {
return inbound.getId();
}
private InboundOrderEntity findByOrder(long tenantId, long orderId, boolean currentRead) {
var query = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(InboundOrderEntity::getOrderId, orderId);
if (currentRead) {
query.last("FOR UPDATE");
}
return orders.selectOne(query);
}
private long requireMatchingInboundCode(InboundOrderEntity existing, String inboundCode) {
if (!java.util.Objects.equals(existing.getInboundCode(), inboundCode)) {
throw new BizException(ResultCode.CONFLICT, "订单入仓码与已有入库单不一致");
}
return existing.getId();
}
public List<InboundOrderReference.Item> findReferences(long tenantId, List<Long> orderIds) {
if (orderIds.isEmpty()) return List.of();
return orders.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InboundOrderEntity>()
@@ -68,6 +68,17 @@ public class MeasurementReviewService {
if (!"SUBMITTED".equals(measurement.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "测量批次不是待审核状态");
}
if (approved) {
long confirmed = repository.listMeasurementItems(tenantId, measurement.getId()).stream()
.map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getPackageCount)
.filter(java.util.Objects::nonNull)
.mapToLong(Integer::longValue)
.sum();
if (order.getTotalPackageCount() == null || confirmed != order.getTotalPackageCount()) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"人工确认箱数合计与入库单总箱数不一致,不能审核通过");
}
}
if (repository.transitionReview(tenantId, inboundId, operatorId,
request.expectedVersion(), approved) != 1) {
@@ -0,0 +1,105 @@
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.PriceBookDtos.CurrentChannel;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateResponse;
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@Service
public class MerchantPriceBookService {
public static final String DISCLAIMER = "仅供参考,不含附加费,最终以实际结算为准";
@Resource private PriceBookQueryRepository repository;
@Resource private ActivePriceBookEstimator estimator;
public List<CurrentChannel> channels(long merchantTenantId, long originWarehouseId) {
requireMerchant(merchantTenantId);
if (originWarehouseId <= 0) throw validation("请选择有效起运仓");
Long versionId = repository.activeVersionId();
if (versionId == null) return List.of();
List<ChannelRow> channels = repository.channels(versionId, originWarehouseId);
if (channels.isEmpty()) return List.of();
Map<String, List<PricingTaxMode>> taxModes = pricingTaxModes(repository.channelTaxModes(versionId, originWarehouseId));
return channels.stream().map(channel -> toChannel(channel, taxModes)).toList();
}
public EstimateResponse estimate(long merchantTenantId, EstimateRequest request) {
requireMerchant(merchantTenantId);
ActivePriceBookEstimator.Estimate estimate = estimator.estimate(request);
return new EstimateResponse(estimate.priceBookVersionId(), estimate.channelCode(), estimate.priceModel(),
estimate.pricingTaxMode(), estimate.chargeableQuantity(), estimate.chargeUnit(), estimate.unitPrice(),
estimate.amount(), estimate.currency(), DISCLAIMER);
}
public void requireAvailableChannel(long merchantTenantId, long originWarehouseId, String channelCode) {
requireMerchant(merchantTenantId);
if (originWarehouseId <= 0) throw validation("请选择有效起运仓");
Long versionId = repository.activeVersionId();
if (versionId == null || !repository.channelAvailable(versionId, originWarehouseId,
normalizeChannel(channelCode))) {
throw new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择");
}
}
private CurrentChannel toChannel(ChannelRow row, Map<String, List<PricingTaxMode>> taxModes) {
String channelCode = normalizeChannel(row.getChannelCode());
return new CurrentChannel(row.getPriceBookVersionId(), row.getChannelCode(), row.getChannelName(),
priceModel(row.getPriceModel()), taxModes.getOrDefault(channelCode, List.of()),
row.getEstimatedDepartureDate(), row.getEstimatedDepartureText(),
row.getLatestWarehouseEntry(), row.getReferenceTransit(), row.getSurchargeDescription(), row.getRemark());
}
private static Map<String, List<PricingTaxMode>> pricingTaxModes(List<ChannelTaxModeRow> rows) {
Map<String, EnumSet<PricingTaxMode>> values = new HashMap<>();
for (ChannelTaxModeRow row : rows) {
if (row == null) continue;
String channelCode = normalizeChannel(row.getChannelCode());
try {
values.computeIfAbsent(channelCode, ignored -> EnumSet.noneOf(PricingTaxMode.class))
.add(PricingTaxMode.valueOf(row.getPricingTaxMode()));
} catch (RuntimeException exception) {
throw new BizException(ResultCode.CONFLICT, "渠道报价包税方式配置无效");
}
}
Map<String, List<PricingTaxMode>> result = new HashMap<>();
values.forEach((channelCode, modes) -> result.put(channelCode, List.copyOf(modes)));
return result;
}
private static PriceModel priceModel(String value) {
try {
return PriceModel.valueOf(value);
} catch (RuntimeException exception) {
throw new BizException(ResultCode.CONFLICT, "渠道价格模型配置无效");
}
}
private static String normalizeChannel(String value) {
if (!StringUtils.hasText(value)) throw validation("请选择有效渠道");
return value.trim().toUpperCase(Locale.ROOT);
}
private static void requireMerchant(long merchantTenantId) {
if (merchantTenantId <= 0) throw new BizException(ResultCode.FORBIDDEN, "仅允许客户端访问");
}
private static BizException validation(String message) {
return new BizException(ResultCode.VALIDATION_FAILED, message);
}
}
@@ -0,0 +1,603 @@
package com.baiyue.firstleg.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.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.PriceRegion;
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.pricing.importer.PriceWorkbookParseResult;
import com.baiyue.firstleg.pricing.importer.PriceWorkbookParser;
import com.baiyue.firstleg.repository.PriceBookRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Service
public class PlatformPriceBookService {
private static final String PENDING_RATE_STATUS = PriceBookStatus.PENDING_CONFIRMATION.name();
private static final BigDecimal ZERO = BigDecimal.ZERO;
private static final TypeReference<List<PriceBookDtos.ParseIssue>> ISSUE_LIST = new TypeReference<>() {};
private static final Set<String> REVALIDATED_ISSUE_CODES = Set.of(
"NO_CHANNELS", "NO_RATES", "DUPLICATE_CHANNEL", "CHANNEL_NOT_MAPPED", "CHANNEL_MODEL_MISMATCH",
"RATE_CHANNEL_MISSING", "RATE_MODEL_MISMATCH", "PRICE_NOT_POSITIVE", "SOURCE_LOCATION_MISSING",
"ORIGIN_MAPPING_REQUIRED", "ORIGIN_WAREHOUSE_INVALID", "OVERSEAS_MAPPING_REQUIRED",
"OVERSEAS_WAREHOUSE_INVALID", "WAREHOUSE_VALIDATION_FAILED", "REGION_TIER_INVALID",
"PRICE_MODEL_REQUIRED", "DESTINATION_TYPE_REQUIRED", "PRICING_TAX_MODE_REQUIRED", "FBA_CODE_REQUIRED",
"DUPLICATE_RATE", "DUPLICATE_ORIGIN_MAPPING", "DUPLICATE_OVERSEAS_MAPPING",
"UNUSED_ORIGIN_MAPPING", "UNUSED_OVERSEAS_MAPPING", "DUPLICATE_WAREHOUSE_MAPPING"
);
@Resource private PriceBookRepository repository;
@Resource private PriceBookFileClient files;
@Resource private PriceBookWarehouseClient warehouses;
@Resource private ObjectMapper objectMapper;
@Resource private TransactionTemplate transactions;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public List<PriceBookDtos.PriceBookVersionSummary> list(PriceBookStatus status) {
String persistedStatus = status == null ? null : status.name();
return repository.listVersions(persistedStatus).stream().map(this::summary).toList();
}
public PriceBookDtos.PriceBookVersionDetail upload(long operatorId, MultipartFile file) {
requireFile(file);
PriceBookVersionEntity version = transactions.execute(status -> createVersion(operatorId, file.getOriginalFilename()));
if (version == null) throw internal("价格版本创建失败");
Path temp = null;
try {
ApiResponse<PriceBookFileClient.FileMetadata> response = files.upload(internalToken, operatorId, version.getId(), file);
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件保存失败");
}
var metadata = response.data();
if (!Objects.equals(metadata.businessId(), version.getId())
|| !Objects.equals(metadata.businessType(), "FBA_PRICE_BOOK_SOURCE")) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件归属校验失败");
}
version.setSourceFileId(metadata.fileId());
version.setSourceSha256(metadata.sha256());
if (metadata.fileId() == null || metadata.fileId() <= 0) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件标识无效");
}
ResponseEntity<byte[]> stored = files.download(internalToken, operatorId, version.getId(), metadata.fileId());
if (stored == null || stored.getBody() == null || stored.getBody().length == 0) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件读取失败");
}
temp = Files.createTempFile("price-book-" + version.getId() + "-", ".xlsx");
Files.write(temp, stored.getBody());
PriceWorkbookParseResult parsed = new PriceWorkbookParser().parse(temp);
ParsedPriceBook parsedPriceBook = parsedPreview(parsed);
transactions.executeWithoutResult(status -> persistParsedPreview(version, parsedPriceBook.channels(), parsedPriceBook.rates(),
mergeIssues(parsedPriceBook.issues(), validate(parsedPriceBook.channels(), parsedPriceBook.rates())), operatorId));
return detail(version.getId());
} catch (Exception exception) {
List<PriceBookDtos.ParseIssue> issues = List.of(issue("BLOCKING", "UPLOAD_OR_PARSE_FAILED",
safeMessage(exception, "价格表上传或解析失败"), null, null, null));
markFailed(version, issues, operatorId);
if (exception instanceof BizException biz) throw biz;
throw new BizException(ResultCode.VALIDATION_FAILED, safeMessage(exception, "价格表解析失败"));
} finally {
if (temp != null) try { Files.deleteIfExists(temp); } catch (IOException ignored) { }
}
}
public PriceBookDtos.PriceBookVersionDetail detail(long versionId) {
PriceBookVersionEntity version = requireVersion(versionId);
List<PriceBookChannelEntity> channelRows = repository.listChannels(versionId);
List<PriceBookDtos.ParseIssue> issues = new ArrayList<>(readIssues(version.getParseIssuesJson()));
if (PriceBookStatus.PENDING_CONFIRMATION.name().equals(version.getStatus())) {
PriceBookVersionEntity active = repository.findActive();
if (active != null && !Objects.equals(active.getId(), versionId)) {
Set<String> current = channelRows.stream().map(value -> normalizeChannel(value.getChannelCode())).collect(java.util.stream.Collectors.toSet());
repository.listChannels(active.getId()).stream()
.filter(value -> !current.contains(normalizeChannel(value.getChannelCode())))
.forEach(value -> issues.add(issue("WARNING", "CHANNEL_MISSING_FROM_PENDING_UPLOAD",
"待确认上传记录未包含上一生效版本渠道,确认生效后该渠道将下线", value.getSourceSheetName(), null, null)));
}
}
return new PriceBookDtos.PriceBookVersionDetail(summary(version),
channelRows.stream().map(this::channelDto).toList(),
repository.listRates(versionId).stream().map(this::rateDto).toList(), issues);
}
@Transactional(noRollbackFor = BizException.class)
public PriceBookDtos.PriceBookVersionDetail publish(long versionId, long operatorId,
PriceBookConfirmationRequest confirmation) {
repository.lockPlatformVersions();
PriceBookVersionEntity version = requirePendingConfirmation(versionId);
List<PriceBookDtos.ParsedChannel> channels = repository.listChannels(versionId).stream().map(this::channelDto).toList();
List<PriceBookDtos.ParsedRate> rates = repository.listRates(versionId).stream().map(this::rateDto).toList();
MappingResult mapped = applyMappings(rates, confirmation);
List<PriceBookDtos.ParseIssue> issues = mergeIssues(
mergeIssues(preservedParseIssues(version), mapped.issues()),
validate(channels, mapped.rates()));
applyIssueCounts(version, issues);
if (version.getBlockingErrorCount() > 0) {
repository.updateVersion(version);
throw new BizException(ResultCode.CONFLICT, "价格表仍有阻断错误,不能生效");
}
repository.deletePendingChildren(versionId);
persistChildren(version, channels, mapped.rates(), operatorId);
version.setChannelCount(channels.size());
version.setRateCount(mapped.rates().size());
LocalDateTime now = LocalDateTime.now();
repository.listActiveForUpdate().stream()
.filter(active -> !Objects.equals(active.getId(), versionId))
.forEach(active -> repository.supersede(active, now, operatorId));
repository.activate(version, now, operatorId);
return detail(versionId);
}
public ResponseEntity<byte[]> source(long versionId, long operatorId) {
PriceBookVersionEntity version = requireVersion(versionId);
if (version.getSourceFileId() == null) throw new BizException(ResultCode.NOT_FOUND, "价格表原文件不存在");
try {
ResponseEntity<byte[]> response = files.download(internalToken, operatorId, versionId, version.getSourceFileId());
if (response == null || response.getBody() == null) throw new BizException(ResultCode.NOT_FOUND, "价格表原文件不存在");
HttpHeaders headers = new HttpHeaders();
headers.putAll(response.getHeaders());
headers.set(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment()
.filename(version.getOriginalFileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString());
headers.set("X-Content-Type-Options", "nosniff");
return new ResponseEntity<>(response.getBody(), headers, response.getStatusCode());
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件服务暂时不可用");
}
}
private PriceBookVersionEntity createVersion(long operatorId, String originalFileName) {
PriceBookVersionEntity value = new PriceBookVersionEntity();
value.setTenantId(PriceBookVersionEntity.PLATFORM_SCOPE);
value.setVersionNo(repository.nextVersionNo());
value.setOriginalFileName(safeFileName(originalFileName));
value.setStatus(PriceBookStatus.PENDING_CONFIRMATION.name());
value.setBlockingErrorCount(0); value.setWarningCount(0); value.setChannelCount(0); value.setRateCount(0);
value.setCreatedBy(operatorId); value.setUpdatedBy(operatorId); value.setDeleted(false); value.setVersion(0);
if (repository.insertVersion(value) != 1) throw internal("价格版本创建失败");
return value;
}
private void persistParsedPreview(PriceBookVersionEntity version, List<PriceBookDtos.ParsedChannel> channels,
List<PriceBookDtos.ParsedRate> rates, List<PriceBookDtos.ParseIssue> issues,
long operatorId) {
repository.deletePendingChildren(version.getId());
persistChildren(version, channels, rates, operatorId);
version.setStatus(PriceBookStatus.PENDING_CONFIRMATION.name());
version.setChannelCount(channels.size()); version.setRateCount(rates.size());
applyIssueCounts(version, issues);
version.setUpdatedBy(operatorId);
repository.updateVersion(version);
}
private void persistChildren(PriceBookVersionEntity version, List<PriceBookDtos.ParsedChannel> channels,
List<PriceBookDtos.ParsedRate> rates, long operatorId) {
LocalDateTime now = LocalDateTime.now();
for (PriceBookDtos.ParsedChannel parsedChannel : channels) {
PriceBookChannelEntity value = new PriceBookChannelEntity();
value.setPriceBookVersionId(version.getId()); value.setChannelCode(normalizeChannel(parsedChannel.channelCode()));
value.setChannelName(parsedChannel.channelName().trim()); value.setPriceModel(parsedChannel.priceModel().name());
value.setSourceSheetName(parsedChannel.sourceSheetName().trim()); value.setEstimatedDepartureDate(parsedChannel.estimatedDepartureDate());
value.setEstimatedDepartureText(trim(parsedChannel.estimatedDepartureText())); value.setLatestWarehouseEntry(trim(parsedChannel.latestWarehouseEntry()));
value.setVesselName(trim(parsedChannel.vesselName())); value.setVoyageNo(trim(parsedChannel.voyageNo()));
value.setReferenceTransit(trim(parsedChannel.referenceTransit())); value.setSurchargeDescription(trim(parsedChannel.surchargeDescription()));
value.setRemark(trim(parsedChannel.remark())); value.setCreatedBy(operatorId); value.setUpdatedBy(operatorId);
value.setCreatedAt(now); value.setUpdatedAt(now); value.setDeleted(false); value.setVersion(0);
repository.insertChannel(value);
}
int number = 1;
for (PriceBookDtos.ParsedRate parsedRate : rates) repository.insertRate(rateEntity(version, parsedRate, operatorId, number++, now));
}
private CarrierFbaRateEntity rateEntity(PriceBookVersionEntity version, PriceBookDtos.ParsedRate parsedRate,
long operatorId, int number, LocalDateTime now) {
CarrierFbaRateEntity value = new CarrierFbaRateEntity();
value.setTenantId(CarrierFbaRateEntity.PLATFORM_SCOPE); value.setPriceBookVersionId(version.getId());
value.setRateCode("PB-" + version.getVersionNo() + "-" + number); value.setRateVersion(1);
value.setOriginWarehouseId(parsedRate.originWarehouseId()); value.setOriginWarehouseCode(parsedRate.originWarehouseCode().trim());
value.setOriginWarehouseName(parsedRate.originWarehouseName().trim()); value.setBusinessType("FBA_SEA");
value.setChannelCode(normalizeChannel(parsedRate.channelCode())); value.setPriceModel(parsedRate.priceModel().name());
value.setCarrierName(parsedRate.channelCode());
value.setDestinationType(parsedRate.destinationType() == null ? null : parsedRate.destinationType().name());
value.setDestinationCode(upper(parsedRate.destinationCode())); value.setDestinationWarehouseId(parsedRate.destinationWarehouseId());
value.setDestinationName(trim(parsedRate.destinationName())); value.setRegionCode(parsedRate.regionCode() == null ? null : parsedRate.regionCode().name());
value.setPricingTaxMode(parsedRate.pricingTaxMode() == null ? null : parsedRate.pricingTaxMode().name());
value.setWeightFromKg(parsedRate.weightFromKg()); value.setChargeUnit(parsedRate.chargeUnit()); value.setUnitPrice(parsedRate.unitPrice());
value.setCurrency(parsedRate.currency()); value.setAdditionalFee(ZERO); value.setSourceSheetName(parsedRate.sourceSheetName());
value.setSourceRowNumber(parsedRate.sourceRowNumber()); value.setSourceDestinationText(trim(parsedRate.sourceDestinationText()));
value.setEffectiveFrom(now); value.setStatus(PENDING_RATE_STATUS); value.setRemark(trim(parsedRate.remark()));
value.setCreatedBy(operatorId); value.setUpdatedBy(operatorId); value.setCreatedAt(now); value.setUpdatedAt(now);
value.setDeleted(false); value.setVersion(0);
return value;
}
private ParsedPriceBook parsedPreview(PriceWorkbookParseResult parsed) {
List<PriceBookDtos.ParsedChannel> channels = new ArrayList<>();
List<PriceBookDtos.ParsedRate> rates = new ArrayList<>();
for (var channel : parsed.channels()) {
String code = normalizeChannel(channel.channelCode());
var sailing = channel.sailings().isEmpty() ? null : channel.sailings().get(0);
channels.add(new PriceBookDtos.ParsedChannel(code, channel.channelName(), channel.priceModel(),
channel.sourceSheet(), sailing == null ? null : sailing.expectedDepartureDate(),
sailing == null ? channel.scheduleText() : sailing.rawText(), channel.cutoffText(),
sailing == null ? null : sailing.vesselName(), sailing == null ? null : sailing.voyageNumber(),
channel.transitTimeText(), channel.surchargeText(), channel.remarkText()));
for (var rate : channel.rates()) rates.add(parsedRate(code, channel.priceModel(), rate));
}
List<PriceBookDtos.ParseIssue> issues = parsed.issues().stream().map(value -> issue(
value.severity() == PriceWorkbookParseResult.Severity.ERROR ? "BLOCKING" : "WARNING",
value.code(), value.message(), value.sheetName(), value.rowNumber(), null)).toList();
return new ParsedPriceBook(channels, applyInitializedWarehouseMappings(rates), issues);
}
private List<PriceBookDtos.ParsedRate> applyInitializedWarehouseMappings(List<PriceBookDtos.ParsedRate> rates) {
Map<String, PriceWarehouseMappingEntity> origins = new HashMap<>();
Map<String, PriceWarehouseMappingEntity> overseas = new HashMap<>();
for (PriceWarehouseMappingEntity mapping : repository.activeWarehouseMappings()) {
if (mapping == null || mapping.getWarehouseId() == null || mapping.getWarehouseId() <= 0) continue;
if ("ORIGIN_CODE".equals(mapping.getSourceType())
&& "DOMESTIC".equals(mapping.getWarehouseType())
&& mappingKey(mapping.getSourceValue()).equals(mappingKey(mapping.getWarehouseCode()))) {
origins.putIfAbsent(mappingKey(mapping.getSourceValue()), mapping);
} else if ("OVERSEAS_NAME".equals(mapping.getSourceType())
&& "OVERSEAS".equals(mapping.getWarehouseType())
&& StringUtils.hasText(mapping.getWarehouseCode())) {
overseas.putIfAbsent(mappingKey(mapping.getSourceValue()), mapping);
}
}
return rates.stream().map(rate -> {
PriceWarehouseMappingEntity origin = origins.get(mappingKey(rate.originWarehouseCode()));
PriceWarehouseMappingEntity destination = rate.destinationType() == PricingDestinationType.OVERSEAS
? overseas.get(mappingKey(rate.destinationName())) : null;
return new PriceBookDtos.ParsedRate(rate.id(), rate.channelCode(), rate.priceModel(),
origin == null ? rate.originWarehouseId() : origin.getWarehouseId(),
rate.originWarehouseCode(), rate.originWarehouseName(),
destination == null ? rate.destinationCode() : mappingKey(destination.getWarehouseCode()), rate.destinationType(),
destination == null ? rate.destinationWarehouseId() : destination.getWarehouseId(),
rate.destinationName(), rate.regionCode(), rate.pricingTaxMode(), rate.chargeUnit(), rate.weightFromKg(),
rate.unitPrice(), rate.currency(), rate.sourceSheetName(), rate.sourceRowNumber(),
rate.sourceDestinationText(), rate.remark());
}).toList();
}
private PriceBookDtos.ParsedRate parsedRate(String channelCode, PriceModel model,
PriceWorkbookParseResult.ParsedRate rate) {
PricingDestinationType destinationType = "OVERSEAS".equals(rate.destinationType())
? PricingDestinationType.OVERSEAS
: model == PriceModel.REGION_WEIGHT_TIER ? null : PricingDestinationType.FBA;
PriceRegion region = rate.regionCode() == null ? null : PriceRegion.valueOf(rate.regionCode());
String originCode = "YIWU".equals(rate.originWarehouseKey()) ? "CN-YIWU-01"
: "DONGGUAN".equals(rate.originWarehouseKey()) ? "CN-DONGGUAN-01" : rate.originWarehouseKey();
return new PriceBookDtos.ParsedRate(null, channelCode, model, null, originCode,
rate.originWarehouseLabel(), rate.destinationCode(), destinationType, null, rate.destinationName(),
region, rate.taxMode(), rate.chargeUnit(), rate.minimumWeightKg(), rate.unitPrice(), "CNY",
rate.sourceSheet(), rate.sourceRow(), rate.rawDestinationGroup(), rate.remarkText());
}
private MappingResult applyMappings(List<PriceBookDtos.ParsedRate> rates,
PriceBookConfirmationRequest confirmation) {
List<PriceBookDtos.ParseIssue> issues = new ArrayList<>();
if (confirmation == null) {
issues.add(issue("BLOCKING", "ORIGIN_MAPPING_REQUIRED", "请确认起运仓映射", null, null, null));
return new MappingResult(rates, issues);
}
Map<String, Long> origins = mappingIndex(confirmation.originMappings(),
PriceBookConfirmationRequest.OriginMapping::originWarehouseCode,
PriceBookConfirmationRequest.OriginMapping::warehouseId,
"DUPLICATE_ORIGIN_MAPPING", "起运仓映射重复", issues);
Map<String, Long> overseas = mappingIndex(confirmation.overseasMappings(),
PriceBookConfirmationRequest.OverseasMapping::sourceDestinationName,
PriceBookConfirmationRequest.OverseasMapping::warehouseId,
"DUPLICATE_OVERSEAS_MAPPING", "海外仓映射重复", issues);
Set<String> sourceOrigins = rates.stream().map(PriceBookDtos.ParsedRate::originWarehouseCode)
.filter(StringUtils::hasText).map(PlatformPriceBookService::mappingKey).collect(java.util.stream.Collectors.toSet());
Set<String> sourceOverseas = rates.stream()
.filter(rate -> rate.destinationType() == PricingDestinationType.OVERSEAS)
.map(PriceBookDtos.ParsedRate::destinationName).filter(StringUtils::hasText)
.map(PlatformPriceBookService::mappingKey).collect(java.util.stream.Collectors.toSet());
origins.keySet().stream().filter(key -> !sourceOrigins.contains(key)).forEach(key ->
issues.add(issue("BLOCKING", "UNUSED_ORIGIN_MAPPING", "起运仓映射不属于当前价格表", null, null, null)));
overseas.keySet().stream().filter(key -> !sourceOverseas.contains(key)).forEach(key ->
issues.add(issue("BLOCKING", "UNUSED_OVERSEAS_MAPPING", "海外仓映射不属于当前价格表", null, null, null)));
List<PriceBookDtos.ParsedRate> mapped = new ArrayList<>(rates.size());
for (PriceBookDtos.ParsedRate rate : rates) {
Long originId = origins.get(mappingKey(rate.originWarehouseCode()));
PricingDestinationType destinationType = rate.priceModel() == PriceModel.REGION_WEIGHT_TIER
? null : rate.destinationType();
Long destinationId = destinationType == PricingDestinationType.OVERSEAS
? overseas.get(mappingKey(rate.destinationName())) : null;
mapped.add(new PriceBookDtos.ParsedRate(rate.id(), rate.channelCode(), rate.priceModel(), originId,
rate.originWarehouseCode(), rate.originWarehouseName(), rate.destinationCode(), destinationType,
destinationId, rate.destinationName(), rate.regionCode(), rate.pricingTaxMode(), rate.chargeUnit(),
rate.weightFromKg(), rate.unitPrice(), rate.currency(), rate.sourceSheetName(), rate.sourceRowNumber(),
rate.sourceDestinationText(), rate.remark()));
}
return new MappingResult(List.copyOf(mapped), List.copyOf(issues));
}
private <T> Map<String, Long> mappingIndex(List<T> mappings,
java.util.function.Function<T, String> key,
java.util.function.Function<T, Long> warehouseId,
String duplicateCode, String duplicateMessage,
List<PriceBookDtos.ParseIssue> issues) {
Map<String, Long> values = new HashMap<>();
Map<Long, String> warehouseKeys = new HashMap<>();
if (mappings == null) return values;
for (T mapping : mappings) {
if (mapping == null) continue;
String normalized = mappingKey(key.apply(mapping));
if (!StringUtils.hasText(normalized) || warehouseId.apply(mapping) == null || warehouseId.apply(mapping) <= 0) {
continue;
}
Long id = warehouseId.apply(mapping);
if (values.putIfAbsent(normalized, id) != null) {
issues.add(issue("BLOCKING", duplicateCode, duplicateMessage, null, null, null));
}
String previousKey = warehouseKeys.putIfAbsent(id, normalized);
if (previousKey != null && !previousKey.equals(normalized)) {
issues.add(issue("BLOCKING", "DUPLICATE_WAREHOUSE_MAPPING",
"不同来源仓不能映射到同一平台仓", null, null, null));
}
}
return values;
}
private List<PriceBookDtos.ParseIssue> preservedParseIssues(PriceBookVersionEntity version) {
return readIssues(version.getParseIssuesJson()).stream()
.filter(issue -> !REVALIDATED_ISSUE_CODES.contains(issue.issueCode())).toList();
}
private List<PriceBookDtos.ParseIssue> validate(List<PriceBookDtos.ParsedChannel> channels,
List<PriceBookDtos.ParsedRate> rates) {
List<PriceBookDtos.ParseIssue> issues = new ArrayList<>();
if (channels == null || channels.isEmpty()) {
issues.add(issue("BLOCKING", "NO_CHANNELS", "价格表未解析出可用渠道", null, null, null));
return issues;
}
if (rates == null || rates.isEmpty()) {
issues.add(issue("BLOCKING", "NO_RATES", "价格表未解析出价格行", null, null, null));
return issues;
}
Map<String, PriceChannelCatalogEntity> catalog = new HashMap<>();
repository.activeCatalog().forEach(value -> catalog.put(value.getChannelCode(), value));
Set<String> channelCodes = new HashSet<>();
for (var channel : channels) {
String code = normalizeChannel(channel.channelCode());
if (!channelCodes.add(code)) issues.add(issue("BLOCKING", "DUPLICATE_CHANNEL", "渠道重复", channel.sourceSheetName(), null, null));
PriceChannelCatalogEntity definition = catalog.get(code);
if (definition == null) issues.add(issue("BLOCKING", "CHANNEL_NOT_MAPPED", "渠道未映射到标准目录", channel.sourceSheetName(), null, null));
else if (channel.priceModel() == null || !definition.getPriceModel().equals(channel.priceModel().name()))
issues.add(issue("BLOCKING", "CHANNEL_MODEL_MISMATCH", "渠道价格模型与目录不一致", channel.sourceSheetName(), null, null));
}
Set<String> unique = new HashSet<>();
Map<Long, PriceBookWarehouseClient.Warehouse> warehouseCache = new HashMap<>();
for (var rate : rates) {
String code = normalizeChannel(rate.channelCode());
if (!channelCodes.contains(code)) issues.add(issue("BLOCKING", "RATE_CHANNEL_MISSING", "价格行没有对应渠道", rate.sourceSheetName(), rate.sourceRowNumber(), null));
PriceChannelCatalogEntity persistedDefinition = catalog.get(code);
if (persistedDefinition != null && rate.priceModel() != null
&& !persistedDefinition.getPriceModel().equals(rate.priceModel().name())) {
issues.add(issue("BLOCKING", "RATE_MODEL_MISMATCH", "价格行模型与渠道目录不一致",
rate.sourceSheetName(), rate.sourceRowNumber(), null));
}
if (rate.priceModel() == null) {
issues.add(issue("BLOCKING", "PRICE_MODEL_REQUIRED", "价格行必须选择价格模型",
rate.sourceSheetName(), rate.sourceRowNumber(), null));
}
if (rate.unitPrice() == null || rate.unitPrice().signum() <= 0) issues.add(issue("BLOCKING", "PRICE_NOT_POSITIVE", "单价必须大于0", rate.sourceSheetName(), rate.sourceRowNumber(), null));
if (rate.sourceRowNumber() == null || rate.sourceRowNumber() <= 0 || !StringUtils.hasText(rate.sourceSheetName()))
issues.add(issue("BLOCKING", "SOURCE_LOCATION_MISSING", "价格行缺少Excel来源位置", rate.sourceSheetName(), rate.sourceRowNumber(), null));
validateOrigin(rate, warehouseCache, issues);
validateDestination(rate, warehouseCache, issues);
String originIdentity = rate.originWarehouseId() == null
? "CODE:" + mappingKey(rate.originWarehouseCode()) : "ID:" + rate.originWarehouseId();
String destinationIdentity = rate.destinationWarehouseId() == null
? "NAME:" + mappingKey(rate.destinationName()) : "ID:" + rate.destinationWarehouseId();
String key = String.join("|", code, originIdentity, enumText(rate.destinationType()),
destinationIdentity, nullToEmpty(upper(rate.destinationCode())),
rate.regionCode() == null ? "" : rate.regionCode().name(), rate.pricingTaxMode() == null ? "" : rate.pricingTaxMode().name(),
rate.chargeUnit(), text(rate.weightFromKg()));
if (!unique.add(key)) issues.add(issue("BLOCKING", "DUPLICATE_RATE", "存在重复价格组合", rate.sourceSheetName(), rate.sourceRowNumber(), null));
}
return issues;
}
private void validateOrigin(PriceBookDtos.ParsedRate rate, Map<Long, PriceBookWarehouseClient.Warehouse> cache,
List<PriceBookDtos.ParseIssue> issues) {
if (rate.originWarehouseId() == null || rate.originWarehouseId() <= 0) {
issues.add(issue("BLOCKING", "ORIGIN_MAPPING_REQUIRED", "起运仓需要映射到平台国内仓", rate.sourceSheetName(), rate.sourceRowNumber(), null));
return;
}
PriceBookWarehouseClient.Warehouse warehouse = warehouse(rate.originWarehouseId(), cache, issues, rate, "起运仓");
if (warehouse != null && (!"DOMESTIC".equals(warehouse.warehouseType())
|| !Objects.equals(warehouse.warehouseCode(), rate.originWarehouseCode()))) {
issues.add(issue("BLOCKING", "ORIGIN_WAREHOUSE_INVALID", "起运仓必须是匹配编码的平台公共国内仓", rate.sourceSheetName(), rate.sourceRowNumber(), null));
}
}
private void validateDestination(PriceBookDtos.ParsedRate rate, Map<Long, PriceBookWarehouseClient.Warehouse> cache,
List<PriceBookDtos.ParseIssue> issues) {
if (rate.priceModel() == PriceModel.REGION_WEIGHT_TIER) {
if (rate.regionCode() == null || rate.weightFromKg() == null || rate.pricingTaxMode() == null
|| rate.chargeUnit() == null || !"KG".equals(rate.chargeUnit()))
issues.add(issue("BLOCKING", "REGION_TIER_INVALID", "海派价格必须包含区域、重量阶梯和KG单位", rate.sourceSheetName(), rate.sourceRowNumber(), null));
return;
}
if (rate.priceModel() == null) {
issues.add(issue("BLOCKING", "PRICE_MODEL_REQUIRED", "价格行必须选择价格模型", rate.sourceSheetName(), rate.sourceRowNumber(), null));
return;
}
if (rate.destinationType() == null) {
issues.add(issue("BLOCKING", "DESTINATION_TYPE_REQUIRED", "FBA仓库价格必须选择目的地类型", rate.sourceSheetName(), rate.sourceRowNumber(), null));
return;
}
if (rate.pricingTaxMode() == null) {
issues.add(issue("BLOCKING", "PRICING_TAX_MODE_REQUIRED", "FBA仓库价格必须选择包税或不包税", rate.sourceSheetName(), rate.sourceRowNumber(), null));
}
if (rate.destinationType() == PricingDestinationType.FBA) {
if (!StringUtils.hasText(rate.destinationCode())) issues.add(issue("BLOCKING", "FBA_CODE_REQUIRED", "FBA价格必须填写目的仓编码", rate.sourceSheetName(), rate.sourceRowNumber(), null));
} else {
if (rate.destinationWarehouseId() == null || rate.destinationWarehouseId() <= 0) {
issues.add(issue("BLOCKING", "OVERSEAS_MAPPING_REQUIRED", "海外仓价格必须映射平台海外仓", rate.sourceSheetName(), rate.sourceRowNumber(), null));
} else {
PriceBookWarehouseClient.Warehouse warehouse = warehouse(rate.destinationWarehouseId(), cache, issues, rate, "海外仓");
if (warehouse != null && (!"OVERSEAS".equals(warehouse.warehouseType())
|| (StringUtils.hasText(rate.destinationCode())
&& !Objects.equals(warehouse.warehouseCode(), upper(rate.destinationCode())))))
issues.add(issue("BLOCKING", "OVERSEAS_WAREHOUSE_INVALID", "目的仓必须是匹配编码的平台公共海外仓", rate.sourceSheetName(), rate.sourceRowNumber(), null));
}
}
}
private PriceBookWarehouseClient.Warehouse warehouse(long id, Map<Long, PriceBookWarehouseClient.Warehouse> cache,
List<PriceBookDtos.ParseIssue> issues,
PriceBookDtos.ParsedRate rate, String label) {
if (cache.containsKey(id)) return cache.get(id);
try {
ApiResponse<PriceBookWarehouseClient.Detail> response = warehouses.active(internalToken, 0L, id);
if (response == null || !response.successful() || response.data() == null || response.data().warehouse() == null)
throw new IllegalStateException("仓库不存在");
var warehouse = response.data().warehouse();
if (!Long.valueOf(id).equals(warehouse.id()) || !Long.valueOf(0L).equals(warehouse.tenantId())
|| !"ACTIVE".equals(warehouse.status())) throw new IllegalStateException("仓库不可用");
cache.put(id, warehouse); return warehouse;
} catch (RuntimeException exception) {
issues.add(issue("BLOCKING", "WAREHOUSE_VALIDATION_FAILED", label + "无法通过平台公共仓库校验",
rate.sourceSheetName(), rate.sourceRowNumber(), null));
cache.put(id, null); return null;
}
}
private void markFailed(PriceBookVersionEntity version, List<PriceBookDtos.ParseIssue> issues, long operatorId) {
if (version == null || version.getId() == null) return;
try {
version.setStatus(PriceBookStatus.FAILED.name()); version.setUpdatedBy(operatorId);
applyIssueCounts(version, issues); repository.updateVersion(version);
} catch (RuntimeException ignored) { }
}
private void applyIssueCounts(PriceBookVersionEntity version, List<PriceBookDtos.ParseIssue> issues) {
version.setBlockingErrorCount((int) issues.stream().filter(i -> "BLOCKING".equals(i.severity())).count());
version.setWarningCount((int) issues.stream().filter(i -> "WARNING".equals(i.severity())).count());
try { version.setParseIssuesJson(objectMapper.writeValueAsString(issues)); }
catch (JsonProcessingException exception) { throw internal("价格表校验结果序列化失败"); }
}
private List<PriceBookDtos.ParseIssue> readIssues(String json) {
if (!StringUtils.hasText(json)) return List.of();
try { return objectMapper.readValue(json, ISSUE_LIST); }
catch (JsonProcessingException exception) { throw internal("价格表校验结果读取失败"); }
}
private PriceBookVersionEntity requireVersion(long id) {
if (id <= 0) throw validation("价格版本ID必须大于0");
PriceBookVersionEntity value = repository.findVersion(id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "价格表版本不存在");
return value;
}
private PriceBookVersionEntity requirePendingConfirmation(long id) {
PriceBookVersionEntity value = requireVersion(id);
if (!PriceBookStatus.PENDING_CONFIRMATION.name().equals(value.getStatus())) throw new BizException(ResultCode.CONFLICT, "只有待确认上传记录可以确认并生效");
return value;
}
private PriceBookDtos.PriceBookVersionSummary summary(PriceBookVersionEntity value) {
PriceBookStatus status = PriceBookStatus.valueOf(value.getStatus());
return new PriceBookDtos.PriceBookVersionSummary(value.getId(), value.getVersionNo(), value.getOriginalFileName(),
value.getSourceFileId(), value.getSourceSha256(), status,
value.getChannelCount(), value.getRateCount(), value.getBlockingErrorCount(), value.getWarningCount(),
value.getParseIssuesJson(), value.getCreatedBy(), value.getCreatedAt(), value.getPublishedBy(),
value.getPublishedAt(), value.getSupersededAt());
}
private PriceBookDtos.ParsedChannel channelDto(PriceBookChannelEntity value) {
return new PriceBookDtos.ParsedChannel(value.getChannelCode(), value.getChannelName(), PriceModel.valueOf(value.getPriceModel()),
value.getSourceSheetName(), value.getEstimatedDepartureDate(), value.getEstimatedDepartureText(),
value.getLatestWarehouseEntry(), value.getVesselName(), value.getVoyageNo(), value.getReferenceTransit(),
value.getSurchargeDescription(), value.getRemark());
}
private PriceBookDtos.ParsedRate rateDto(CarrierFbaRateEntity value) {
return new PriceBookDtos.ParsedRate(value.getId(), value.getChannelCode(), PriceModel.valueOf(value.getPriceModel()),
value.getOriginWarehouseId(), value.getOriginWarehouseCode(), value.getOriginWarehouseName(),
value.getDestinationCode(), value.getDestinationType() == null ? null : PricingDestinationType.valueOf(value.getDestinationType()),
value.getDestinationWarehouseId(), value.getDestinationName(),
value.getRegionCode() == null ? null : PriceRegion.valueOf(value.getRegionCode()),
value.getPricingTaxMode() == null ? null : PricingTaxMode.valueOf(value.getPricingTaxMode()),
value.getChargeUnit(), value.getWeightFromKg(), value.getUnitPrice(), value.getCurrency(),
value.getSourceSheetName(), value.getSourceRowNumber(), value.getSourceDestinationText(), value.getRemark());
}
private static void requireFile(MultipartFile file) {
if (file == null || file.isEmpty()) throw validation("请选择价格表Excel文件");
String name = file.getOriginalFilename();
if (!StringUtils.hasText(name) || !name.toLowerCase(Locale.ROOT).endsWith(".xlsx")) throw validation("仅支持XLSX价格表");
}
private static String safeFileName(String value) {
String name = value == null ? "price-book.xlsx" : value.replace('\\', '/');
name = name.substring(name.lastIndexOf('/') + 1).trim();
return name.isEmpty() ? "price-book.xlsx" : name.substring(0, Math.min(name.length(), 255));
}
private static String normalizeChannel(String value) { return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); }
private static String upper(String value) { return value == null ? null : value.trim().toUpperCase(Locale.ROOT); }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String text(Object value) { return value == null ? "" : value.toString(); }
private static String nullToEmpty(String value) { return value == null ? "" : value; }
private static String enumText(Enum<?> value) { return value == null ? "" : value.name(); }
private static String mappingKey(String value) { return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); }
private static String safeMessage(Exception exception, String fallback) {
return StringUtils.hasText(exception.getMessage()) ? exception.getMessage() : fallback;
}
private static List<PriceBookDtos.ParseIssue> mergeIssues(List<PriceBookDtos.ParseIssue> first,
List<PriceBookDtos.ParseIssue> second) {
Map<String, PriceBookDtos.ParseIssue> result = new LinkedHashMap<>();
java.util.stream.Stream.concat(first.stream(), second.stream()).forEach(value -> result.put(
String.join("|", value.severity(), value.issueCode(), nullToEmpty(value.sourceSheetName()),
text(value.sourceRowNumber()), nullToEmpty(value.sourceCell())), value));
return List.copyOf(result.values());
}
private static PriceBookDtos.ParseIssue issue(String severity, String code, String message,
String sheet, Integer row, String cell) {
return new PriceBookDtos.ParseIssue(severity, code, message, sheet, row, cell);
}
private static BizException validation(String message) { return new BizException(ResultCode.VALIDATION_FAILED, message); }
private static BizException internal(String message) { return new BizException(ResultCode.INTERNAL_ERROR, message); }
private record ParsedPriceBook(List<PriceBookDtos.ParsedChannel> channels, List<PriceBookDtos.ParsedRate> rates,
List<PriceBookDtos.ParseIssue> issues) {}
private record MappingResult(List<PriceBookDtos.ParsedRate> rates, List<PriceBookDtos.ParseIssue> issues) {}
}

Some files were not shown because too many files have changed in this diff Show More