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:
@@ -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);
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -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
@@ -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) {}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+67
@@ -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());
|
||||
}
|
||||
}
|
||||
+72
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
+61
@@ -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, "内部服务凭证无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -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, "内部服务凭证无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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));
|
||||
}
|
||||
}
|
||||
+50
@@ -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));
|
||||
}
|
||||
}
|
||||
+49
-2
@@ -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,
|
||||
|
||||
+24
@@ -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);
|
||||
}
|
||||
|
||||
+84
@@ -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) {}
|
||||
}
|
||||
+54
@@ -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) {}
|
||||
}
|
||||
+90
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
+52
@@ -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);
|
||||
}
|
||||
}
|
||||
+38
@@ -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) {
|
||||
}
|
||||
}
|
||||
+102
@@ -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(),
|
||||
|
||||
+91
@@ -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();
|
||||
|
||||
+32
-5
@@ -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
Reference in New Issue
Block a user