二期内容提交

This commit is contained in:
2026-08-04 10:07:45 +08:00
parent b2b4ff429d
commit 57b54a5236
184 changed files with 13195 additions and 5 deletions
@@ -76,6 +76,7 @@ public class BaiyueSecurityAutoConfiguration {
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
.requestMatchers("/internal/**").permitAll()
.requestMatchers("/openapi/**").permitAll()
.requestMatchers("/api/merchant/**").hasAuthority("APP_MERCHANT")
.requestMatchers("/api/platform/**").hasAuthority("APP_PLATFORM")
.anyRequest().authenticated())
+1
View File
@@ -7,3 +7,4 @@ spring:
# 当前代码只实现本地/共享文件目录,非本地环境必须显式注入并挂载可写目录。
baiyue.file.storage-root: ${FILE_STORAGE_ROOT}
baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:}
+18
View File
@@ -4,3 +4,21 @@ spring:
username: ${FIRSTLEG_DB_USERNAME}
password: ${FIRSTLEG_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
firstleg:
inbound:
allow-rejected-measurement: false
default-pallet-capacity-cbm: ${DEFAULT_PALLET_CAPACITY_CBM:1.200000}
reservation-ttl: ${PALLET_RESERVATION_TTL:30m}
measurement-openapi:
enabled: ${MEASUREMENT_OPENAPI_ENABLED:false}
max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m}
nonce-ttl: ${MEASUREMENT_OPENAPI_NONCE_TTL:10m}
apps:
device-provider:
enabled: ${MEASUREMENT_DEVICE_PROVIDER_ENABLED:false}
secret: ${MEASUREMENT_DEVICE_PROVIDER_SECRET:}
tenant-id: ${MEASUREMENT_DEVICE_PROVIDER_TENANT_ID:0}
subject-id: ${MEASUREMENT_DEVICE_PROVIDER_SUBJECT_ID:0}
warehouse-bindings:
CN-YW-01: ${MEASUREMENT_DEVICE_PROVIDER_WAREHOUSE_ID:0}
+1
View File
@@ -4,3 +4,4 @@ spring.datasource:
username: ${FILE_DB_USERNAME:root}
password: ${FILE_DB_PASSWORD:123456}
baiyue.file.storage-root: ${FILE_STORAGE_ROOT:D:/baiyue-firstleg-runtime/uploads}
baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:}
+18
View File
@@ -3,3 +3,21 @@ spring.datasource:
url: ${FIRSTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${FIRSTLEG_DB_USERNAME:root}
password: ${FIRSTLEG_DB_PASSWORD:123456}
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
firstleg:
inbound:
allow-rejected-measurement: false
default-pallet-capacity-cbm: ${DEFAULT_PALLET_CAPACITY_CBM:1.200000}
reservation-ttl: ${PALLET_RESERVATION_TTL:30m}
measurement-openapi:
enabled: ${MEASUREMENT_OPENAPI_ENABLED:false}
max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m}
nonce-ttl: ${MEASUREMENT_OPENAPI_NONCE_TTL:10m}
apps:
device-provider:
enabled: ${MEASUREMENT_DEVICE_PROVIDER_ENABLED:false}
secret: ${MEASUREMENT_DEVICE_PROVIDER_SECRET:}
tenant-id: ${MEASUREMENT_DEVICE_PROVIDER_TENANT_ID:0}
subject-id: ${MEASUREMENT_DEVICE_PROVIDER_SUBJECT_ID:0}
warehouse-bindings:
CN-YW-01: ${MEASUREMENT_DEVICE_PROVIDER_WAREHOUSE_ID:0}
@@ -0,0 +1,699 @@
-- 柏悦头程二期入库表结构:TEST环境一键执行脚本
-- 生成日期:2026-08-03
-- 适用版本:MySQL 8.0及以上
-- 执行范围:只新增二期表、字段、索引和约束,不插入、更新或删除业务数据
-- 目标数据库:baiyue_firstleg、baiyue_warehouse
-- 前置条件:一期基线表 fl_fulfillment_task、wh_warehouse 已存在
-- 注意:MySQL DDL会自动提交,请使用具有目标库建表权限的测试环境账号执行
SET NAMES utf8mb4;
SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';
SELECT VERSION() AS mysql_version,
'开始执行二期入库表结构' AS execution_message;
SET NAMES utf8mb4;
CREATE DATABASE IF NOT EXISTS baiyue_firstleg
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_inbound_order (
id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
task_id BIGINT UNSIGNED NULL COMMENT '头程履约任务ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID,不建立跨库外键',
order_no VARCHAR(32) NOT NULL COMMENT '订单号快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '租户内唯一入仓码',
process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态',
order_status_snapshot VARCHAR(24) NOT NULL COMMENT '订单状态快照',
measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量数据审核状态',
fee_status VARCHAR(24) NOT NULL DEFAULT 'UNPRICED' COMMENT '费用业务状态',
fee_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态',
notification_status VARCHAR(24) NOT NULL DEFAULT 'NOT_CREATED' COMMENT '客户通知状态',
expected_arrival_at DATETIME(3) NULL COMMENT '预计到达时间',
customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
company_id BIGINT UNSIGNED NULL COMMENT '运营公司逻辑ID',
company_name VARCHAR(128) NULL COMMENT '运营公司名称快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '实际作业仓库ID',
warehouse_name VARCHAR(128) NOT NULL COMMENT '实际作业仓库名称快照',
delivery_warehouse_id BIGINT UNSIGNED NULL COMMENT '交货仓逻辑ID',
delivery_warehouse_name VARCHAR(128) NULL COMMENT '交货仓名称快照',
business_type VARCHAR(32) NOT NULL COMMENT '业务类型',
channel_code VARCHAR(64) NULL COMMENT '渠道编码',
channel_name VARCHAR(128) NULL COMMENT '渠道名称快照',
tax_mode VARCHAR(24) NULL COMMENT '买单或退税模式',
container_mode VARCHAR(24) NULL COMMENT '整柜或散货模式',
customs_inspection TINYINT(1) NULL COMMENT '是否商检',
destination_type VARCHAR(24) NULL COMMENT '目的仓类型',
destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓逻辑ID',
destination_code VARCHAR(32) NULL COMMENT '目的仓编码,例如FBA仓编码',
destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货',
current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照',
total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数',
actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认后的实际总毛重kg',
chargeable_weight_kg DECIMAL(18,3) NULL COMMENT '当前费用快照的计费重量kg',
actual_volume_cbm DECIMAL(18,4) NULL COMMENT '确认后的实际总体积m3',
chargeable_volume_cbm DECIMAL(18,4) NULL COMMENT '当前费用快照的计费体积m3',
pallet_count INT UNSIGNED NULL COMMENT '已分配总托盘数快照',
volume_weight_kg DECIMAL(18,3) NULL COMMENT '总材积重kg',
actual_net_weight_kg DECIMAL(18,3) NULL COMMENT '总净重kg',
measure_completed_at DATETIME(3) NULL COMMENT '测量完成时间',
inbounded_at DATETIME(3) NULL COMMENT '入库完成时间',
express_company_code VARCHAR(64) NULL COMMENT '快递公司编码快照',
express_company_name VARCHAR(128) NULL COMMENT '快递公司名称快照',
express_tracking_no VARCHAR(128) NULL COMMENT '快递单号快照',
public_remark VARCHAR(1000) NULL COMMENT '客户可见备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
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_inbound_tenant_code (tenant_id, inbound_code),
UNIQUE KEY uk_inbound_tenant_order (tenant_id, order_id),
KEY idx_inbound_process_time (tenant_id, process_status, created_at, id),
KEY idx_inbound_measure_time (tenant_id, measure_review_status, created_at, id),
KEY idx_inbound_fee_time (tenant_id, fee_review_status, created_at, id),
KEY idx_inbound_warehouse_status (tenant_id, warehouse_id, process_status, created_at),
KEY idx_inbound_customer_time (tenant_id, customer_id, created_at),
KEY idx_inbound_company_time (tenant_id, company_id, created_at),
KEY idx_inbound_arrival (tenant_id, expected_arrival_at),
KEY idx_inbound_destination (tenant_id, destination_code, created_at),
KEY idx_inbound_channel (tenant_id, channel_code, created_at),
CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id)
) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照';
CREATE TABLE IF NOT EXISTS fl_inbound_pickup_snapshot (
id BIGINT UNSIGNED NOT NULL COMMENT '提货信息快照ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
pickup_method VARCHAR(24) NULL COMMENT '提货方式',
pickup_at DATETIME(3) NULL COMMENT '提货时间',
contact_name VARCHAR(64) NULL COMMENT '提货联系人',
contact_phone VARCHAR(32) NULL COMMENT '提货联系人电话',
contact_email VARCHAR(128) NULL COMMENT '提货联系人邮箱',
country_code CHAR(2) NULL COMMENT '国家二字码',
province VARCHAR(64) NULL COMMENT '省或州',
city VARCHAR(64) NULL COMMENT '城市',
district VARCHAR(64) NULL COMMENT '区县',
address_line VARCHAR(512) NULL COMMENT '提货详细地址',
postal_code VARCHAR(20) NULL COMMENT '提货邮编',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_pickup_inbound (tenant_id, inbound_id),
CONSTRAINT fk_pickup_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单提货信息不可变快照';
CREATE TABLE IF NOT EXISTS fl_inbound_destination_snapshot (
id BIGINT UNSIGNED NOT NULL COMMENT '目的仓地址快照ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
destination_type VARCHAR(24) NULL COMMENT '目的仓类型',
destination_code VARCHAR(32) NULL COMMENT '目的仓编码',
destination_name VARCHAR(128) NULL COMMENT '目的仓名称',
country_code CHAR(2) NULL COMMENT '国家二字码',
state_province VARCHAR(100) NULL COMMENT '省或州',
city VARCHAR(100) NULL COMMENT '城市',
address_line VARCHAR(512) NULL COMMENT '详细地址',
postal_code VARCHAR(20) NULL COMMENT '邮编',
contact_name VARCHAR(64) NULL COMMENT '联系人',
contact_phone VARCHAR(32) NULL COMMENT '联系人电话',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_destination_inbound (tenant_id, inbound_id),
CONSTRAINT fk_destination_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单目的仓地址和联系人快照';
CREATE TABLE IF NOT EXISTS fl_inbound_transport_snapshot (
id BIGINT UNSIGNED NOT NULL COMMENT '运输信息快照ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
loading_status VARCHAR(24) NULL COMMENT '配载状态快照',
container_no VARCHAR(64) NULL COMMENT '集装箱号',
cabinet_no VARCHAR(64) NULL COMMENT '柜号或内部柜单号',
vessel_name VARCHAR(128) NULL COMMENT '船名',
voyage_no VARCHAR(64) NULL COMMENT '航次',
sailing_at DATETIME(3) NULL COMMENT '开船时间',
port_arrival_at DATETIME(3) NULL COMMENT '到港时间',
source_updated_at DATETIME(3) 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_transport_inbound (tenant_id, inbound_id),
KEY idx_transport_container (tenant_id, container_no),
KEY idx_transport_cabinet (tenant_id, cabinet_no),
KEY idx_transport_loading (tenant_id, loading_status, sailing_at),
CONSTRAINT fk_transport_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单关联运输信息只读快照';
CREATE TABLE IF NOT EXISTS fl_inbound_measurement (
id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
source_type VARCHAR(24) NOT NULL COMMENT '数据来源:THIRD_PARTY或MANUAL',
source_code VARCHAR(64) NULL COMMENT '第三方应用或来源编码',
external_event_no VARCHAR(128) NULL COMMENT '第三方事件号',
device_code VARCHAR(64) NULL COMMENT '测量设备编码',
warehouse_code VARCHAR(50) NULL COMMENT '第三方回传仓库编码',
measured_at DATETIME(3) NULL COMMENT '设备测量时间',
status VARCHAR(24) NOT NULL DEFAULT 'RECEIVED' COMMENT '测量批次状态',
raw_payload_json JSON NULL COMMENT '第三方原始报文',
total_package_count INT UNSIGNED NULL COMMENT '本批次总箱数',
total_volume_raw_cbm DECIMAL(18,6) NULL COMMENT '本批次原始总体积m3',
total_volume_cbm DECIMAL(18,4) NULL COMMENT '本批次业务总体积m3',
created_by BIGINT UNSIGNED NULL COMMENT '创建人或系统主体ID',
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_external_event (tenant_id, source_code, external_event_no),
KEY idx_measure_inbound_time (tenant_id, inbound_id, created_at),
CONSTRAINT fk_measure_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库长宽高测量批次和第三方原始数据';
CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
id BIGINT UNSIGNED NOT NULL COMMENT '测量明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
measurement_id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID',
item_no VARCHAR(32) NOT NULL COMMENT '批次内明细号',
package_count INT UNSIGNED NOT NULL COMMENT '相同箱规箱数',
length_cm DECIMAL(12,3) NOT NULL COMMENT '原始长度cm',
width_cm DECIMAL(12,3) NOT NULL COMMENT '原始宽度cm',
height_cm DECIMAL(12,3) NOT NULL COMMENT '原始高度cm',
volume_raw_cbm DECIMAL(18,6) NOT NULL COMMENT '原始体积m3',
volume_cbm DECIMAL(18,4) NOT NULL COMMENT '业务体积m3',
calculation_rule_version VARCHAR(32) NOT NULL COMMENT '体积计算和舍入规则版本',
source_image_file_id BIGINT UNSIGNED NULL COMMENT '测量设备图片文件ID',
remark VARCHAR(500) NULL COMMENT '测量明细备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_measure_item_no (tenant_id, measurement_id, item_no),
CONSTRAINT fk_measure_item_batch FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (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',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
file_id BIGINT UNSIGNED NOT NULL COMMENT '称重图片文件ID',
ocr_provider VARCHAR(64) NULL COMMENT 'OCR服务商编码',
ocr_raw_text VARCHAR(1000) NULL COMMENT 'OCR原始文本',
ocr_weight_value DECIMAL(18,3) NULL COMMENT 'OCR识别原始重量值',
ocr_weight_unit VARCHAR(8) NULL COMMENT 'OCR识别原始单位',
confidence DECIMAL(6,5) NULL COMMENT '识别置信度',
adjusted_weight_value DECIMAL(18,3) NULL COMMENT '人工修正后的原单位重量',
adjusted_weight_unit VARCHAR(8) NULL COMMENT '人工修正后的单位',
final_weight_kg DECIMAL(18,3) NULL COMMENT '确认并统一换算后的重量kg',
recognition_status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '识别和确认状态',
adjustment_reason VARCHAR(500) NULL COMMENT '人工修正原因',
adjusted_by BIGINT UNSIGNED NULL COMMENT '修正人ID',
adjusted_at DATETIME(3) NULL COMMENT '修正时间',
confirmed_by BIGINT UNSIGNED NULL COMMENT '确认人ID',
confirmed_at DATETIME(3) 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 '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_weight_inbound_file (tenant_id, inbound_id, file_id),
KEY idx_weight_inbound_status (tenant_id, inbound_id, recognition_status),
CONSTRAINT fk_weight_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='称重图片OCR原值、人工修正值和确认重量';
CREATE TABLE IF NOT EXISTS fl_measure_review_record (
id BIGINT UNSIGNED NOT NULL COMMENT '测量审核记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
measurement_id BIGINT UNSIGNED NULL COMMENT '被审核的测量批次ID',
review_result VARCHAR(24) NOT NULL COMMENT '审核结果',
review_reason VARCHAR(1000) NULL COMMENT '审核或驳回原因',
exception_release TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否异常放行',
release_reason VARCHAR(1000) NULL COMMENT '异常放行原因',
allow_rejected_config TINYINT(1) NOT NULL DEFAULT 0 COMMENT '操作时Nacos异常放行开关值',
reviewer_id BIGINT UNSIGNED NOT NULL COMMENT '审核人ID',
reviewer_name VARCHAR(64) NULL COMMENT '审核人名称快照',
reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '审核时间',
PRIMARY KEY (id),
KEY idx_review_inbound_time (tenant_id, inbound_id, reviewed_at),
CONSTRAINT fk_review_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT fk_review_measurement FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id)
) ENGINE=InnoDB COMMENT='测量审核、驳回和异常放行不可变记录';
CREATE TABLE IF NOT EXISTS fl_customer_charge_rule (
id BIGINT UNSIGNED NOT NULL COMMENT '客户计费规则ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
rule_code VARCHAR(50) NOT NULL COMMENT '租户内规则编码',
rule_version INT UNSIGNED NOT NULL COMMENT '规则版本号',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户逻辑ID',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
business_type VARCHAR(32) NOT NULL COMMENT '业务类型',
charge_mode VARCHAR(16) NOT NULL COMMENT '计费口径:KG或CBM',
tax_mode VARCHAR(24) NULL COMMENT '包税或不包税类型',
conversion_factor DECIMAL(18,6) NOT NULL COMMENT '重量体积换算系数',
rounding_scale TINYINT UNSIGNED NOT NULL DEFAULT 3 COMMENT '结果保留小数位数',
rounding_mode VARCHAR(24) NOT NULL DEFAULT 'CEILING' COMMENT '舍入方式',
currency CHAR(3) NOT NULL COMMENT '默认币种',
effective_from DATETIME(3) NOT NULL COMMENT '生效开始时间',
effective_to DATETIME(3) NULL COMMENT '生效结束时间',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态',
remark VARCHAR(500) 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 '逻辑删除标记',
PRIMARY KEY (id),
UNIQUE KEY uk_charge_rule_version (tenant_id, rule_code, rule_version),
KEY idx_charge_rule_match (tenant_id, customer_id, business_type, status, effective_from, effective_to)
) ENGINE=InnoDB COMMENT='客户头程计费规则版本';
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',
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_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照',
channel_code VARCHAR(64) NULL COMMENT '渠道编码',
carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID',
carrier_name VARCHAR(128) NOT NULL COMMENT '船司名称快照',
destination_code VARCHAR(32) NOT NULL COMMENT 'FBA目的仓编码',
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 '固定附加费',
effective_from DATETIME(3) NOT NULL COMMENT '生效开始时间',
effective_to DATETIME(3) NULL COMMENT '生效结束时间',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '价格状态',
remark VARCHAR(500) 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 '逻辑删除标记',
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, channel_code, destination_code, charge_unit, status, effective_from)
) ENGINE=InnoDB COMMENT='船司FBA目的仓价格版本';
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 '客户规则版本快照',
carrier_rate_id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID',
carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照',
charge_mode VARCHAR(16) NOT NULL COMMENT '计费口径:KG或CBM',
conversion_factor DECIMAL(18,6) NOT 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 '换算后的重量或体积',
chargeable_quantity DECIMAL(18,4) NOT NULL COMMENT '最终计费量',
charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位',
currency CHAR(3) NOT NULL COMMENT '币种',
subtotal_amount DECIMAL(18,2) NOT NULL COMMENT '基础费用金额',
additional_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '附加费金额',
total_amount DECIMAL(18,2) NOT NULL COMMENT '总金额',
fee_status VARCHAR(24) NOT NULL DEFAULT 'CALCULATED' COMMENT '费用状态',
review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态',
exception_release TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否基于测量异常放行生成',
calculated_by BIGINT UNSIGNED NOT NULL COMMENT '试算人ID',
calculated_at DATETIME(3) NOT NULL COMMENT '试算时间',
confirmed_by BIGINT UNSIGNED NULL COMMENT '确认人ID',
confirmed_at DATETIME(3) NULL COMMENT '确认时间',
sent_by BIGINT UNSIGNED NULL COMMENT '发送人ID',
sent_at DATETIME(3) 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_inbound_fee_no (tenant_id, fee_no),
UNIQUE KEY uk_inbound_fee_version (tenant_id, inbound_id, fee_version),
KEY idx_inbound_fee_status (tenant_id, inbound_id, fee_status, review_status),
CONSTRAINT fk_fee_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT fk_fee_charge_rule FOREIGN KEY (charge_rule_id) REFERENCES fl_customer_charge_rule (id),
CONSTRAINT fk_fee_carrier_rate FOREIGN KEY (carrier_rate_id) REFERENCES fl_carrier_fba_rate (id)
) ENGINE=InnoDB COMMENT='入库头程费用计算和确认快照';
CREATE TABLE IF NOT EXISTS fl_inbound_fee_item (
id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
fee_id BIGINT UNSIGNED NOT NULL COMMENT '费用头ID',
item_no INT UNSIGNED NOT NULL COMMENT '费用单内序号',
fee_code VARCHAR(50) NOT NULL COMMENT '费用项目编码',
fee_name VARCHAR(128) NOT NULL COMMENT '费用项目名称快照',
quantity DECIMAL(18,4) NOT NULL COMMENT '计费数量',
unit VARCHAR(16) NOT NULL COMMENT '计费单位',
unit_price DECIMAL(18,6) NOT NULL COMMENT '单价',
amount DECIMAL(18,2) NOT NULL COMMENT '金额',
currency CHAR(3) NOT NULL COMMENT '币种',
automatic_flag TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否自动计算',
remark VARCHAR(500) NULL COMMENT '费用备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fee_item_no (tenant_id, fee_id, item_no),
CONSTRAINT fk_fee_item_header FOREIGN KEY (fee_id) REFERENCES fl_inbound_fee (id)
) ENGINE=InnoDB COMMENT='入库头程费用项目明细';
CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation (
id BIGINT UNSIGNED NOT NULL COMMENT '头程托盘分配引用ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
allocation_no INT UNSIGNED NOT NULL COMMENT '入库单内分配序号',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务仓库ID',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务托盘ID',
pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照',
pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘可用容积快照',
allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本托盘分配体积',
allocated_package_count INT UNSIGNED NULL COMMENT '本托盘分配箱数',
location_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务库位ID',
location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照',
reservation_no VARCHAR(64) NOT NULL COMMENT '仓库库位预占单号',
allocation_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED' 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_pallet_allocation_no (tenant_id, inbound_id, allocation_no),
UNIQUE KEY uk_pallet_allocation_pallet (tenant_id, inbound_id, pallet_id),
KEY idx_allocation_location (tenant_id, warehouse_id, location_id, allocation_status),
CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='头程侧托盘、库位和仓库预占结果引用';
CREATE TABLE IF NOT EXISTS fl_inbound_file (
id BIGINT UNSIGNED NOT NULL COMMENT '入库单文件关系ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
file_id BIGINT UNSIGNED NOT NULL COMMENT '文件服务文件ID',
file_type VARCHAR(24) NOT NULL COMMENT '文件类型:RELATED、MARK、PC_ORDER、MEASURE或WEIGHT',
file_name_snapshot VARCHAR(255) NOT NULL COMMENT '文件名快照',
visibility VARCHAR(16) NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围',
file_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '业务文件版本',
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传人ID',
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_file_relation (tenant_id, inbound_id, file_id, file_type),
KEY idx_inbound_file_type (tenant_id, inbound_id, file_type, deleted),
CONSTRAINT fk_file_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单与文件中心对象的分类关系';
CREATE TABLE IF NOT EXISTS fl_inbound_hold_record (
id BIGINT UNSIGNED NOT NULL COMMENT '扣货记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
hold_action VARCHAR(16) NOT NULL COMMENT '动作:HOLD或RELEASE',
reason VARCHAR(1000) NOT NULL COMMENT '扣货或解除原因',
evidence_file_id BIGINT UNSIGNED NULL COMMENT '证据文件ID',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
operated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
PRIMARY KEY (id),
KEY idx_hold_inbound_time (tenant_id, inbound_id, operated_at),
CONSTRAINT fk_hold_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单扣货和解除扣货不可变历史';
CREATE TABLE IF NOT EXISTS fl_inbound_internal_note (
id BIGINT UNSIGNED NOT NULL COMMENT '客服内部备注ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
content VARCHAR(2000) NOT NULL COMMENT '仅内部可见备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_internal_note_time (tenant_id, inbound_id, created_at),
CONSTRAINT fk_internal_note_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单客服内部备注历史';
CREATE TABLE IF NOT EXISTS fl_inbound_status_history (
id BIGINT UNSIGNED NOT NULL COMMENT '入库状态历史ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
from_status VARCHAR(24) NULL COMMENT '变更前状态',
to_status VARCHAR(24) NOT NULL COMMENT '变更后状态',
action VARCHAR(32) NOT NULL COMMENT '触发动作',
reason VARCHAR(1000) NULL COMMENT '变更原因',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发生时间',
PRIMARY KEY (id),
KEY idx_inbound_history_time (tenant_id, inbound_id, occurred_at),
CONSTRAINT fk_history_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单流程状态不可变历史';
CREATE TABLE IF NOT EXISTS fl_inbound_outbox (
id BIGINT UNSIGNED NOT NULL COMMENT '领域事件全局ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '事件所属租户ID',
aggregate_type VARCHAR(32) NOT NULL DEFAULT 'INBOUND' COMMENT '聚合类型',
aggregate_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
payload_json JSON NOT NULL COMMENT '事件负载快照',
publish_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态',
retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
next_retry_at DATETIME(3) NULL COMMENT '下次重试时间',
published_at DATETIME(3) NULL COMMENT '发布时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_inbound_outbox_publish (publish_status, next_retry_at, created_at)
) ENGINE=InnoDB COMMENT='入库事务Outbox事件表';
CREATE TABLE IF NOT EXISTS fl_inbound_idempotency (
id BIGINT UNSIGNED NOT NULL COMMENT '入库写操作幂等记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
operation VARCHAR(40) NOT NULL COMMENT '操作类型',
operator_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '操作者或外部应用ID',
inbound_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '入库单ID,创建前可为0',
idempotency_key VARCHAR(128) NOT NULL COMMENT '幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求内容摘要',
status VARCHAR(16) NOT NULL COMMENT '处理状态',
result_json JSON NULL COMMENT '成功结果摘要',
completed_at DATETIME(3) 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_inbound_idempotency (tenant_id, operation, operator_id, idempotency_key),
KEY idx_inbound_idempotency_created (created_at)
) ENGINE=InnoDB COMMENT='第三方测量、审核、费用发送和入库写操作幂等记录';
-- 二期托盘、库位、预占与占用表结构。
-- 本脚本只新增表、字段、索引和约束,不包含业务数据及流程实现。
-- 可在 warehouse.sql 和一期增量脚本之后执行;所有建表均可重复执行。
SET NAMES utf8mb4;
CREATE DATABASE IF NOT EXISTS baiyue_warehouse
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_warehouse;
CREATE TABLE IF NOT EXISTS wh_pallet (
id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
pallet_code VARCHAR(50) NOT NULL COMMENT '租户仓库内唯一托盘编码',
pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型',
pallet_name VARCHAR(100) NULL COMMENT '托盘名称',
capacity_cbm DECIMAL(18,6) NOT NULL COMMENT '托盘可用容积CBM',
status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE、RESERVED、OCCUPIED或DISABLED',
remark VARCHAR(500) 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_pallet_tenant_warehouse_code (tenant_id, warehouse_id, pallet_code),
KEY idx_pallet_available (tenant_id, warehouse_id, status, deleted),
KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, status, deleted),
CONSTRAINT fk_pallet_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id),
CONSTRAINT ck_pallet_capacity CHECK (capacity_cbm > 0)
) ENGINE=InnoDB COMMENT='二期托盘基础资料和物理托盘实例';
CREATE TABLE IF NOT EXISTS wh_location (
id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
zone_code VARCHAR(50) NULL COMMENT '库区编码',
zone_name VARCHAR(100) NULL COMMENT '库区名称',
location_code VARCHAR(50) NOT NULL COMMENT '租户仓库内唯一库位编码',
location_name VARCHAR(100) NULL COMMENT '库位名称',
location_type VARCHAR(32) NOT NULL DEFAULT 'PALLET' COMMENT '库位类型,二期为PALLET',
max_weight_kg DECIMAL(18,3) NULL COMMENT '最大承重展示值,二期不作容量拦截',
status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE、RESERVED、OCCUPIED或DISABLED',
remark VARCHAR(500) 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_location_tenant_warehouse_code (tenant_id, warehouse_id, location_code),
KEY idx_location_available (tenant_id, warehouse_id, status, deleted),
KEY idx_location_zone (tenant_id, warehouse_id, zone_code, status, deleted),
CONSTRAINT fk_location_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id),
CONSTRAINT ck_location_max_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0)
) ENGINE=InnoDB COMMENT='二期库位基础资料,默认一托盘对应一个库位且不作容量校验';
CREATE TABLE IF NOT EXISTS wh_location_reservation (
id BIGINT UNSIGNED NOT NULL COMMENT '库位预占ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
reservation_no VARCHAR(40) NOT NULL COMMENT '租户内唯一预占业务号',
idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方幂等键',
source_service VARCHAR(40) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '来源服务',
business_id BIGINT UNSIGNED NOT NULL COMMENT '来源服务入库单ID',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID',
pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照',
location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID',
location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照',
reservation_status VARCHAR(20) NOT NULL DEFAULT 'RESERVED' COMMENT 'RESERVED、CONFIRMED、RELEASED或EXPIRED',
expires_at DATETIME(3) NOT NULL COMMENT '预占过期时间',
confirmed_at DATETIME(3) NULL COMMENT '确认占用时间',
released_at DATETIME(3) NULL COMMENT '释放时间',
release_reason VARCHAR(500) NULL COMMENT '释放或过期原因',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人或系统主体ID',
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 '更新时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
active_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' THEN 1 ELSE NULL END) STORED COMMENT '活动预占唯一标记',
PRIMARY KEY (id),
UNIQUE KEY uk_location_reservation_no (tenant_id, reservation_no),
UNIQUE KEY uk_location_reservation_idempotency (tenant_id, source_service, idempotency_key),
UNIQUE KEY uk_location_active_reservation (tenant_id, location_id, active_reservation),
KEY idx_location_reservation_business (tenant_id, source_service, business_id, reservation_status),
KEY idx_location_reservation_expire (reservation_status, expires_at),
KEY idx_location_reservation_target (tenant_id, warehouse_id, location_id, reservation_status),
CONSTRAINT fk_reservation_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id),
CONSTRAINT fk_reservation_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id),
CONSTRAINT fk_reservation_location FOREIGN KEY (location_id) REFERENCES wh_location (id)
) ENGINE=InnoDB COMMENT='跨服务入库Saga的托盘和库位临时预占';
CREATE TABLE IF NOT EXISTS wh_location_occupancy (
id BIGINT UNSIGNED NOT NULL COMMENT '库位占用ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
occupancy_no VARCHAR(40) NOT NULL COMMENT '租户内唯一占用业务号',
reservation_id BIGINT UNSIGNED NOT NULL COMMENT '来源预占ID',
source_service VARCHAR(40) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '来源服务',
business_id BIGINT UNSIGNED NOT NULL COMMENT '来源服务入库单ID',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID',
pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照',
location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID',
location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照',
allocated_volume_cbm DECIMAL(18,6) NOT NULL COMMENT '本托盘分配体积CBM',
allocated_package_count INT UNSIGNED NULL COMMENT '本托盘分配箱数',
occupancy_status VARCHAR(20) NOT NULL DEFAULT 'OCCUPIED' COMMENT 'OCCUPIED或RELEASED',
occupied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '占用开始时间',
released_at DATETIME(3) NULL COMMENT '释放时间',
release_reason VARCHAR(500) NULL COMMENT '释放原因',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人或系统主体ID',
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 '更新时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
active_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' THEN 1 ELSE NULL END) STORED COMMENT '活动占用唯一标记',
PRIMARY KEY (id),
UNIQUE KEY uk_location_occupancy_no (tenant_id, occupancy_no),
UNIQUE KEY uk_occupancy_reservation (tenant_id, reservation_id),
UNIQUE KEY uk_location_active_occupancy (tenant_id, location_id, active_occupancy),
UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_occupancy),
KEY idx_occupancy_business (tenant_id, source_service, business_id, occupancy_status),
KEY idx_occupancy_warehouse (tenant_id, warehouse_id, occupancy_status, occupied_at),
CONSTRAINT fk_occupancy_reservation FOREIGN KEY (reservation_id) REFERENCES wh_location_reservation (id),
CONSTRAINT fk_occupancy_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id),
CONSTRAINT fk_occupancy_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id),
CONSTRAINT fk_occupancy_location FOREIGN KEY (location_id) REFERENCES wh_location (id),
CONSTRAINT ck_occupancy_volume CHECK (allocated_volume_cbm > 0)
) ENGINE=InnoDB COMMENT='已确认的一托一库位占用事实记录';
-- 头程入库单属于其他数据库,不建立跨库外键。
-- 执行后结构核对:只输出检查结果,不写入业务数据。
SELECT '头程二期表数量' AS check_item,
COUNT(*) AS actual_value,
19 AS expected_value,
IF(COUNT(*) = 19, 'PASS', 'FAIL') AS check_result
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'baiyue_firstleg'
AND TABLE_NAME IN (
'fl_inbound_order',
'fl_inbound_pickup_snapshot',
'fl_inbound_destination_snapshot',
'fl_inbound_transport_snapshot',
'fl_inbound_measurement',
'fl_inbound_measurement_item',
'fl_inbound_weight_result',
'fl_measure_review_record',
'fl_customer_charge_rule',
'fl_carrier_fba_rate',
'fl_inbound_fee',
'fl_inbound_fee_item',
'fl_inbound_pallet_allocation',
'fl_inbound_file',
'fl_inbound_hold_record',
'fl_inbound_internal_note',
'fl_inbound_status_history',
'fl_inbound_outbox',
'fl_inbound_idempotency'
)
UNION ALL
SELECT '仓库二期表数量' AS check_item,
COUNT(*) AS actual_value,
4 AS expected_value,
IF(COUNT(*) = 4, 'PASS', 'FAIL') AS check_result
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'baiyue_warehouse'
AND TABLE_NAME IN (
'wh_pallet',
'wh_location',
'wh_location_reservation',
'wh_location_occupancy'
);
SELECT '关键唯一索引数量' AS check_item,
COUNT(DISTINCT CONCAT(TABLE_SCHEMA, '.', TABLE_NAME, '.', INDEX_NAME)) AS actual_value,
5 AS expected_value,
IF(COUNT(DISTINCT CONCAT(TABLE_SCHEMA, '.', TABLE_NAME, '.', INDEX_NAME)) = 5, 'PASS', 'FAIL') AS check_result
FROM information_schema.STATISTICS
WHERE (TABLE_SCHEMA = 'baiyue_firstleg'
AND TABLE_NAME = 'fl_inbound_order'
AND INDEX_NAME IN ('uk_inbound_tenant_code', 'uk_inbound_tenant_order'))
OR (TABLE_SCHEMA = 'baiyue_warehouse'
AND TABLE_NAME = 'wh_location_reservation'
AND INDEX_NAME = 'uk_location_active_reservation')
OR (TABLE_SCHEMA = 'baiyue_warehouse'
AND TABLE_NAME = 'wh_location_occupancy'
AND INDEX_NAME IN ('uk_location_active_occupancy', 'uk_pallet_active_occupancy'));
SELECT '关键字段数量' AS check_item,
COUNT(*) AS actual_value,
8 AS expected_value,
IF(COUNT(*) = 8, 'PASS', 'FAIL') AS check_result
FROM information_schema.COLUMNS
WHERE (TABLE_SCHEMA = 'baiyue_firstleg'
AND TABLE_NAME = 'fl_inbound_order'
AND COLUMN_NAME IN (
'inbound_code',
'measure_review_status',
'fee_review_status',
'expected_arrival_at',
'chargeable_weight_kg',
'chargeable_volume_cbm'
))
OR (TABLE_SCHEMA = 'baiyue_warehouse'
AND TABLE_NAME = 'wh_location_reservation'
AND COLUMN_NAME = 'active_reservation')
OR (TABLE_SCHEMA = 'baiyue_warehouse'
AND TABLE_NAME = 'wh_location_occupancy'
AND COLUMN_NAME = 'active_occupancy');
SELECT '二期入库表结构执行完成,请确认以上检查全部为PASS' AS execution_message;
@@ -0,0 +1,23 @@
package com.baiyue.file.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.MediaType;
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;
import java.math.BigDecimal;
@FeignClient(name = "integration-service", contextId = "measurementWeightOcrClient",
url = "${baiyue.clients.integration.url:}", path = "/internal/integrations/weight-ocr")
public interface WeightOcrClient {
@PostMapping(value = "/single", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
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) {}
}
@@ -0,0 +1,98 @@
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.MeasurementImageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
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;
/** 平台入库测量图片上传、OCR 和受控读取入口。 */
@RestController
@RequestMapping("/api/platform/v1/files/measurements")
@Tag(name = "平台入库测量图片")
public class PlatformMeasurementImageController {
private final MeasurementImageService images;
public PlatformMeasurementImageController(MeasurementImageService images) {
this.images = images;
}
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')")
@Operation(summary = "上传称重图片并执行 OCR",
description = "图片先持久化再识别;OCR 失败时保留文件,允许重试或人工录入。")
public ApiResponse<MeasurementImageService.UploadResult> upload(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId,
@PathVariable long inboundId,
@RequestPart("file") MultipartFile file) {
long operatorId = requirePlatform(user);
requirePositive(tenantId, inboundId);
return ApiResponse.success(images.upload(tenantId, inboundId, operatorId, file));
}
@PostMapping("/{tenantId}/{inboundId}/{fileId}/recognize")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')")
@Operation(summary = "重试称重图片 OCR")
public ApiResponse<MeasurementImageService.UploadResult> retryRecognition(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId,
@PathVariable long inboundId,
@PathVariable long fileId) {
requirePlatform(user);
requirePositive(tenantId, inboundId, fileId);
return ApiResponse.success(images.retryRecognition(tenantId, inboundId, fileId));
}
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:view')")
@Operation(summary = "查看入库单称重图片")
public ResponseEntity<byte[]> download(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId,
@PathVariable long inboundId,
@PathVariable long fileId) {
requirePlatform(user);
requirePositive(tenantId, inboundId, fileId);
var image = images.download(tenantId, inboundId, 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 long requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())
|| user.userId() == null || user.userId() <= 0) {
throw new BizException(ResultCode.FORBIDDEN, "需要平台端身份");
}
return user.userId();
}
private void requirePositive(long... ids) {
for (long id : ids) {
if (id <= 0) throw new BizException(ResultCode.BAD_REQUEST, "业务ID必须大于0");
}
}
}
@@ -0,0 +1,34 @@
package com.baiyue.file.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.time.LocalDateTime;
@Getter
@Setter
@TableName("file_object")
public class FileObjectEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String businessType;
private String businessId;
private String originalName;
private String objectKey;
private String storageProvider;
private String bucketName;
private String contentType;
private Long sizeBytes;
private String sha256;
private String status;
private Long createdBy;
private LocalDateTime createdAt;
private Long updatedBy;
private LocalDateTime updatedAt;
private Boolean deleted;
private Integer version;
}
@@ -0,0 +1,9 @@
package com.baiyue.file.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.file.entity.FileObjectEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface FileObjectMapper extends BaseMapper<FileObjectEntity> {
}
@@ -0,0 +1,123 @@
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.client.WeightOcrClient;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Service
public class MeasurementImageService {
private final MeasurementImageStorage storage;
private final FileObjectMapper files;
private final WeightOcrClient ocr;
private final String internalToken;
public MeasurementImageService(MeasurementImageStorage storage, FileObjectMapper files,
WeightOcrClient ocr,
@Value("${baiyue.security.internal-service-token}") String internalToken) {
this.storage = storage;
this.files = files;
this.ocr = ocr;
this.internalToken = internalToken;
}
@Transactional
public UploadResult upload(long tenantId, long inboundId, long operatorId, MultipartFile file) {
var stored = storage.store(tenantId, inboundId, file);
FileObjectEntity object = new FileObjectEntity();
object.setTenantId(tenantId);
object.setBusinessType("INBOUND_WEIGHT_IMAGE");
object.setBusinessId(Long.toString(inboundId));
object.setOriginalName(stored.originalName());
object.setObjectKey(stored.objectKey());
object.setStorageProvider("LOCAL");
object.setBucketName("measurement-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);
files.insert(object);
return recognize(object);
}
public UploadResult retryRecognition(long tenantId, long inboundId, long fileId) {
return recognize(requireObject(tenantId, inboundId, fileId));
}
public Download download(long tenantId, long inboundId, long fileId) {
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
return new Download(object.getOriginalName(), object.getContentType(), storage.read(object.getObjectKey()));
}
private FileObjectEntity requireObject(long tenantId, long inboundId, long fileId) {
FileObjectEntity object = files.selectOne(new LambdaQueryWrapper<FileObjectEntity>()
.eq(FileObjectEntity::getTenantId, tenantId)
.eq(FileObjectEntity::getId, fileId)
.eq(FileObjectEntity::getBusinessType, "INBOUND_WEIGHT_IMAGE")
.eq(FileObjectEntity::getBusinessId, Long.toString(inboundId))
.eq(FileObjectEntity::getDeleted, false));
if (object == null) throw new BizException(ResultCode.NOT_FOUND, "称重图片不存在");
return object;
}
private UploadResult recognize(FileObjectEntity object) {
byte[] content = storage.read(object.getObjectKey());
MultipartFile image = new StoredImageMultipartFile(
object.getOriginalName(), object.getContentType(), content);
try {
var response = ocr.recognize(internalToken, image);
if (response == null || !response.successful() || response.data() == null) {
return failed(object, response == null ? "OCR服务无响应" : response.message());
}
var result = response.data();
return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(),
object.getSizeBytes(), result.status(), result.weight(), "KG", result.confidence(), result.error());
} catch (FeignException exception) {
return failed(object, "OCR服务不可用,可稍后重试或人工录入");
}
}
private UploadResult failed(FileObjectEntity object, String message) {
return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(),
object.getSizeBytes(), "FAILED", null, null, null, message);
}
public record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes,
String recognitionStatus, BigDecimal weightValue, String weightUnit,
Double confidence, String error) {}
public record Download(String fileName, String contentType, byte[] content) {}
private record StoredImageMultipartFile(String originalFilename, String contentType, byte[] bytes)
implements MultipartFile {
@Override public String getName() { return "file"; }
@Override public String getOriginalFilename() { return originalFilename; }
@Override public String getContentType() { return contentType; }
@Override public boolean isEmpty() { return bytes.length == 0; }
@Override public long getSize() { return bytes.length; }
@Override public byte[] getBytes() { return bytes.clone(); }
@Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); }
@Override public void transferTo(java.io.File destination) throws IOException {
java.nio.file.Files.write(destination.toPath(), bytes);
}
}
}
@@ -0,0 +1,89 @@
package com.baiyue.file.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
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;
@Component
public class MeasurementImageStorage {
private static final long MAX_SIZE = 5L * 1024 * 1024;
private static final Set<String> TYPES = Set.of("image/jpeg", "image/png", "image/bmp");
private final Path root;
public MeasurementImageStorage(@Value("${baiyue.file.storage-root}") Path root) {
this.root = root.toAbsolutePath().normalize();
}
public Stored store(long tenantId, long inboundId, 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、BMP");
verifyImage(file);
String extension = "image/png".equals(type) ? ".png" : ("image/bmp".equals(type) ? ".bmp" : ".jpg");
String objectKey = "measurements/" + tenantId + "/" + inboundId + "/" + 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, target, 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, "称重图片读取失败"); }
}
private void verifyImage(MultipartFile file) {
try (InputStream input = file.getInputStream()) {
if (ImageIO.read(input) == null) throw bad("上传内容不是有效图片");
} 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 "weight-image";
return Path.of(name).getFileName().toString();
}
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
public record Stored(String objectKey, Path path, String contentType, long size, String sha256,
String originalName) {}
}
+2
View File
@@ -31,6 +31,8 @@ baiyue:
clients:
iam:
url: ${IAM_SERVICE_URL:}
integration:
url: ${INTEGRATION_SERVICE_URL:}
file:
storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads}
management.endpoints.web.exposure.include: health,info,prometheus
@@ -0,0 +1,59 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.service.MeasurementImageService;
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.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
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;
class PlatformMeasurementImageControllerContractTest {
private final MeasurementImageService images = mock(MeasurementImageService.class);
private final PlatformMeasurementImageController controller =
new PlatformMeasurementImageController(images);
@Test
void 暴露平台测量图片multipart入口并校验重量维护权限() throws Exception {
RequestMapping base = PlatformMeasurementImageController.class.getAnnotation(RequestMapping.class);
assertThat(base.value()).containsExactly("/api/platform/v1/files/measurements");
Method method = PlatformMeasurementImageController.class.getDeclaredMethod("upload",
AuthenticatedUser.class, long.class, long.class, org.springframework.web.multipart.MultipartFile.class);
assertThat(method.getAnnotation(PostMapping.class).consumes())
.containsExactly(MediaType.MULTIPART_FORM_DATA_VALUE);
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:inbound:weight:edit')");
}
@Test
void 商户身份不能调用平台上传接口() {
var file = new MockMultipartFile("file", "weight.png", MediaType.IMAGE_PNG_VALUE, new byte[]{1});
var merchant = new AuthenticatedUser(2L, 10001L, 10001L, 2L,
"merchant", "MERCHANT", "WEB", Set.of());
assertThatThrownBy(() -> controller.upload(merchant, 10001L, 88L, file))
.isInstanceOf(BizException.class);
}
@Test
void 下载始终同时使用租户入库单和文件ID隔离() {
var platform = new AuthenticatedUser(1L, 0L, 0L, 1L,
"admin", "PLATFORM", "WEB", Set.of());
org.mockito.Mockito.when(images.download(10001L, 88L, 99L)).thenReturn(
new MeasurementImageService.Download("weight.png", MediaType.IMAGE_PNG_VALUE, new byte[]{1, 2}));
controller.download(platform, 10001L, 88L, 99L);
verify(images).download(10001L, 88L, 99L);
}
}
+2 -1
View File
@@ -14,6 +14,8 @@
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-sentinel</artifactId></dependency>
<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.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
@@ -21,4 +23,3 @@
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -3,11 +3,13 @@ package com.baiyue.firstleg;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
/**
* 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。
*/
@MapperScan("com.baiyue.firstleg.mapper")
@EnableFeignClients(basePackages = "com.baiyue.firstleg.client")
@SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueFirstlegApplication {
public static void main(String[] args) { SpringApplication.run(BaiyueFirstlegApplication.class, args); }
@@ -0,0 +1,42 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@FeignClient(name = "warehouse-service", contextId = "warehousePalletLocationClient",
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1/pallet-locations")
public interface WarehousePalletLocationClient {
@PostMapping("/reservations")
ApiResponse<BatchResult> reserve(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ReserveRequest request);
@PostMapping("/confirm")
ApiResponse<BatchResult> confirm(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ConfirmRequest request);
@PostMapping("/release")
ApiResponse<List<ActiveResource>> release(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ReleaseRequest request);
record ReserveRequest(long tenantId, long warehouseId, String sourceService, long businessId,
String inboundCode, String idempotencyKey, long operatorId,
BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm, String palletType,
LocalDateTime expiresAt, boolean measurementCompleted) {}
record ConfirmRequest(long tenantId, long warehouseId, String sourceService, long businessId,
long operatorId, BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm) {}
record ReleaseRequest(long tenantId, String sourceService, long businessId, long operatorId,
String reason) {}
record AllocationResult(long reservationId, String reservationNo, int allocationNo,
long palletId, String palletCode, long locationId, String locationCode,
BigDecimal allocatedVolumeCbm, String state) {}
record BatchResult(int palletCount, List<AllocationResult> allocations) {}
record ActiveResource(long reservationId, long warehouseId, long palletId, String palletCode,
long locationId, String locationCode, String state) {}
}
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.config;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 头程入库动态配置。代码默认值必须保持关闭,避免 Nacos 缺失时异常放行。
*/
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "firstleg.inbound")
public class InboundProperties {
private boolean allowRejectedMeasurement = false;
private BigDecimal defaultPalletCapacityCbm = new BigDecimal("1.200000");
private Duration reservationTtl = Duration.ofMinutes(30);
}
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.config;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** 第三方测量接口独立 Swagger 分组和 HMAC 请求头定义。 */
@Configuration
@SecurityScheme(name = "measurementAppKey", type = SecuritySchemeType.APIKEY,
in = SecuritySchemeIn.HEADER, paramName = "X-App-Key", description = "分配给测量服务商的应用标识")
@SecurityScheme(name = "measurementTimestamp", type = SecuritySchemeType.APIKEY,
in = SecuritySchemeIn.HEADER, paramName = "X-Timestamp", description = "Unix 毫秒时间戳")
@SecurityScheme(name = "measurementNonce", type = SecuritySchemeType.APIKEY,
in = SecuritySchemeIn.HEADER, paramName = "X-Nonce", description = "每次请求唯一的随机串")
@SecurityScheme(name = "measurementSignature", type = SecuritySchemeType.APIKEY,
in = SecuritySchemeIn.HEADER, paramName = "X-Signature", description = "签名串的 HMAC-SHA256 小写十六进制值")
public class MeasurementOpenApiDocumentationConfiguration {
@Bean
GroupedOpenApi measurementExternalApi() {
return GroupedOpenApi.builder()
.group("measurement-openapi")
.displayName("Third-party Measurement OpenAPI / 第三方测量开放接口")
.pathsToMatch("/openapi/v1/inbound-measurements/**")
.build();
}
}
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 第三方测量开放接口配置。密钥只能由环境变量或配置中心密文注入。
*/
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "firstleg.measurement-openapi")
public class MeasurementOpenApiProperties {
private boolean enabled = false;
private Duration maxClockSkew = Duration.ofMinutes(5);
private Duration nonceTtl = Duration.ofMinutes(10);
private Map<String, AppCredential> apps = new LinkedHashMap<>();
@Getter
@Setter
public static class AppCredential {
private boolean enabled = true;
private String secret;
private Long tenantId;
private Long subjectId;
private Map<String, Long> warehouseBindings = new LinkedHashMap<>();
}
}
@@ -0,0 +1,39 @@
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.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.MerchantInboundBillingResponse;
import com.baiyue.firstleg.service.MerchantInboundBillingService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "客户端入库结果与费用")
@RestController
@RequestMapping("/api/merchant/v1/first-leg/orders")
public class MerchantInboundBillingController {
private final MerchantInboundBillingService service;
public MerchantInboundBillingController(MerchantInboundBillingService service) {
this.service = service;
}
@GetMapping("/{orderId}/inbound-billing")
@PreAuthorize("hasAuthority('merchant:order:view')")
@Operation(summary = "查询订单入库结果和最新费用",
description = "客户只读查看客服审核通过后发布的费用,不需要确认费用。")
public ApiResponse<MerchantInboundBillingResponse> find(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long orderId) {
if (user == null || !"MERCHANT".equals(user.appCode()) || user.tenantId() == null) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许客户端访问");
}
return ApiResponse.success(service.find(user.tenantId(), orderId));
}
}
@@ -0,0 +1,75 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.EventResponse;
import com.baiyue.firstleg.security.MeasurementRequestHeaders;
import com.baiyue.firstleg.service.InboundMeasurementOpenApiService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Pattern;
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.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 第三方设备长宽高测量开放接口。 */
@Validated
@RestController
@RequestMapping("/openapi/v1/inbound-measurements")
@Tag(name = "第三方入库测量 OpenAPI", description = "按入仓码接收长宽高结果,只落测量数据,不审核、计费或入库")
@SecurityRequirement(name = "measurementAppKey")
@SecurityRequirement(name = "measurementTimestamp")
@SecurityRequirement(name = "measurementNonce")
@SecurityRequirement(name = "measurementSignature")
public class OpenApiInboundMeasurementController {
private final InboundMeasurementOpenApiService service;
public OpenApiInboundMeasurementController(InboundMeasurementOpenApiService service) {
this.service = service;
}
@Operation(summary = "提交第三方长宽高测量结果",
description = "签名正文为 METHOD、PATH、时间戳、Nonce、Idempotency-Key、原始 body SHA-256 六行;只保存调用日志、测量批次和明细")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "成功或业务失败,按响应体 code 判断"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "413", description = "请求体超过网关限制"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "415", description = "Content-Type 不支持")
})
@PostMapping
public ApiResponse<EventResponse> receive(
@RequestHeader("X-App-Key") String appKey,
@RequestHeader("X-Timestamp") String timestamp,
@RequestHeader("X-Nonce") String nonce,
@RequestHeader("X-Signature") String signature,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@io.swagger.v3.oas.annotations.parameters.RequestBody(required = true,
description = "原始 UTF-8 JSON;任何空白差异都会改变签名")
@RequestBody String rawBody) {
return ApiResponse.success(service.receive(
new MeasurementRequestHeaders(appKey, timestamp, nonce, signature, idempotencyKey), rawBody));
}
@Operation(summary = "查询第三方测量事件状态",
description = "GET 签名的 Idempotency-Key 行和 body SHA-256 分别为空字符串、空字节 SHA-256")
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200", description = "查询成功或业务失败,按响应体 code 判断"))
@GetMapping("/events/{eventNo}")
public ApiResponse<EventResponse> query(
@Parameter(description = "第三方业务事件号", required = true)
@PathVariable @Pattern(regexp = "[A-Za-z0-9._:-]{1,128}") String eventNo,
@RequestHeader("X-App-Key") String appKey,
@RequestHeader("X-Timestamp") String timestamp,
@RequestHeader("X-Nonce") String nonce,
@RequestHeader("X-Signature") String signature) {
return ApiResponse.success(service.query(eventNo,
new MeasurementRequestHeaders(appKey, timestamp, nonce, signature, null)));
}
}
@@ -0,0 +1,108 @@
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.FbaPricingDtos.CarrierRateVersionRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.service.FbaMasterDataService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
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.RestController;
import java.util.List;
/** 平台端客户计费规则和船司 FBA 价格版本管理。 */
@Tag(name = "FBA计费基础资料")
@RestController
@RequestMapping("/api/platform/v1/fba-pricing")
public class PlatformFbaMasterDataController {
private final FbaMasterDataService service;
public PlatformFbaMasterDataController(FbaMasterDataService service) { this.service = service; }
@GetMapping("/customer-rules")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:view')")
public ApiResponse<List<CustomerChargeRuleEntity>> rules(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@RequestParam(required = false) String ruleCode) {
return ApiResponse.success(service.listRules(FirstlegScopeGuard.platform(user, tenantId), ruleCode));
}
@GetMapping("/customer-rules/{id}")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:view')")
public ApiResponse<CustomerChargeRuleEntity> rule(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long id) {
return ApiResponse.success(service.rule(FirstlegScopeGuard.platform(user, tenantId), id));
}
@PostMapping("/customer-rules/versions")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:edit')")
@BusinessAudit(event = "FBA_RULE_VERSION_CREATE", resourceType = "CUSTOMER_CHARGE_RULE",
description = "创建客户FBA计费规则版本")
public ApiResponse<CustomerChargeRuleEntity> createRule(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@Valid @RequestBody CustomerRuleVersionRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.createRuleVersion(scope, user.userId(), request));
}
@DeleteMapping("/customer-rules/{id}")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:edit')")
@BusinessAudit(event = "FBA_RULE_RETIRE", resourceType = "CUSTOMER_CHARGE_RULE", resourceId = "#id",
description = "停用客户FBA计费规则版本")
public ApiResponse<Void> retireRule(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId, @PathVariable long id) {
service.retireRule(FirstlegScopeGuard.platform(user, tenantId), id, user.userId());
return ApiResponse.success(null);
}
@GetMapping("/carrier-rates")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
public ApiResponse<List<CarrierFbaRateEntity>> rates(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@RequestParam(required = false) String rateCode) {
return ApiResponse.success(service.listRates(FirstlegScopeGuard.platform(user, tenantId), rateCode));
}
@GetMapping("/carrier-rates/{id}")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
public ApiResponse<CarrierFbaRateEntity> rate(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long id) {
return ApiResponse.success(service.rate(FirstlegScopeGuard.platform(user, tenantId), id));
}
@PostMapping("/carrier-rates/versions")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:edit')")
@BusinessAudit(event = "FBA_RATE_VERSION_CREATE", resourceType = "CARRIER_FBA_RATE",
description = "创建船司FBA价格版本")
public ApiResponse<CarrierFbaRateEntity> createRate(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@Valid @RequestBody CarrierRateVersionRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.createRateVersion(scope, user.userId(), request));
}
@DeleteMapping("/carrier-rates/{id}")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:edit')")
@BusinessAudit(event = "FBA_RATE_RETIRE", resourceType = "CARRIER_FBA_RATE", resourceId = "#id",
description = "停用船司FBA价格版本")
public ApiResponse<Void> retireRate(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId, @PathVariable long id) {
service.retireRate(FirstlegScopeGuard.platform(user, tenantId), id, user.userId());
return ApiResponse.success(null);
}
}
@@ -0,0 +1,90 @@
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.FbaPricingDtos.CalculateRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
import com.baiyue.firstleg.service.FbaPricingService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
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.PathVariable;
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.RequestParam;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RestController;
/** 平台端入库单 FBA 费用试算、确认和发送接口。 */
@Tag(name = "入库FBA计费")
@RestController
@RequestMapping("/api/platform/v1/inbounds/{inboundId}/fees")
public class PlatformFbaPricingController {
private final FbaPricingService service;
public PlatformFbaPricingController(FbaPricingService service) { this.service = service; }
@PostMapping("/calculate")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:calculate')")
@BusinessAudit(event = "FBA_FEE_CALCULATE", resourceType = "INBOUND", resourceId = "#inboundId",
description = "试算FBA费用")
public ApiResponse<FeeSnapshotResponse> calculate(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody(required = false) CalculateRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.calculate(scope, user.userId(), inboundId,
request == null ? null : request.pricingAt(), idempotencyKey));
}
@PostMapping("/confirm")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:confirm')")
@BusinessAudit(event = "FBA_FEE_CONFIRM", resourceType = "INBOUND", resourceId = "#inboundId",
description = "确认FBA费用快照")
public ApiResponse<FeeSnapshotResponse> confirm(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.confirm(scope, user.userId(), inboundId, idempotencyKey));
}
@PostMapping("/send")
@PreAuthorize("hasAuthority('firstleg:inbound:send-fee')")
@BusinessAudit(event = "FBA_FEE_SEND", resourceType = "INBOUND", resourceId = "#inboundId",
description = "发送FBA费用快照并进入待托盘状态")
public ApiResponse<FeeSnapshotResponse> send(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.send(scope, user.userId(), inboundId, idempotencyKey));
}
@GetMapping("/latest")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:view')")
public ApiResponse<FeeSnapshotResponse> latest(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId) {
return ApiResponse.success(service.latest(FirstlegScopeGuard.platform(user, tenantId), inboundId));
}
@PutMapping("/published")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:adjust')")
@BusinessAudit(event = "FBA_FEE_ADJUST", resourceType = "INBOUND", resourceId = "#inboundId",
description = "调整已发布入库费用,不回退入库状态")
public ApiResponse<FeeSnapshotResponse> adjustPublished(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody AdjustFeeRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.adjustPublished(
scope, user.userId(), inboundId, request, idempotencyKey));
}
}
@@ -0,0 +1,134 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
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.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.service.InboundMeasurementService;
import com.baiyue.firstleg.service.MeasurementSubmissionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
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;
/** 平台后台入库测量录入、提交和 OCR 重量结果维护接口。 */
@Tag(name = "平台入库测量", description = "维护测量草稿、箱规明细和可追溯 OCR 重量结果。")
@RestController
@RequestMapping("/api/platform/v1/first-leg/inbounds/{inboundId}")
public class PlatformInboundMeasurementController {
private final InboundMeasurementService service;
private final MeasurementSubmissionService submission;
public PlatformInboundMeasurementController(InboundMeasurementService service,
MeasurementSubmissionService submission) {
this.service = service;
this.submission = submission;
}
@Operation(summary = "查询入库测量详情", description = "返回原单、实测箱规、审核快照和 OCR 重量结果。")
@GetMapping("/measurement")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:view')")
public ApiResponse<InboundMeasurementResponses.Detail> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.detail(tenantId, inboundId));
}
@BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "保存入库测量草稿")
@Operation(summary = "保存测量草稿", description = "保存人工测量批次和服务端计算明细,不推进入库状态。")
@PutMapping("/measurement/draft")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')")
public ApiResponse<InboundMeasurementResponses.DraftSaved> saveDraft(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@Valid @RequestBody InboundMeasurementRequests.SaveDraftRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.saveDraft(tenantId, inboundId, user.userId(), request));
}
@BusinessAudit(event = "INBOUND_MEASUREMENT_SUBMIT", resourceType = "INBOUND",
resourceId = "#inboundId", description = "确认测量并提交审核")
@Operation(summary = "确认测量并提交审核",
description = "校验测量图片和全部 OCR 重量后,最多推进到 MEASURE_PENDING。")
@PostMapping("/measurement/submit")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:submit')")
public ApiResponse<InboundMeasurementResponses.MeasurementSubmitted> submit(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@Valid @RequestBody InboundMeasurementRequests.SubmitRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(submission.submit(tenantId, inboundId, user.userId(), request));
}
@BusinessAudit(event = "INBOUND_WEIGHT_OCR_RECORD", resourceType = "INBOUND",
resourceId = "#inboundId", description = "登记称重图片 OCR 原值")
@Operation(summary = "登记 OCR 重量结果", description = "按图片文件 ID 保存不可覆盖的 OCR 原值。")
@PostMapping("/weight-results")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')")
public ApiResponse<InboundMeasurementResponses.WeightResult> createWeightResult(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@Valid @RequestBody InboundMeasurementRequests.CreateWeightResultRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.createWeightResult(tenantId, inboundId, user.userId(), request));
}
@BusinessAudit(event = "INBOUND_WEIGHT_OCR_CORRECT", resourceType = "INBOUND_WEIGHT_RESULT",
resourceId = "#resultId", description = "人工修正 OCR 重量")
@Operation(summary = "人工修正 OCR 重量", description = "保留 OCR 原值并新增不可变修正版本。")
@PutMapping("/weight-results/{resultId}")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')")
public ApiResponse<InboundMeasurementResponses.WeightResult> correctWeightResult(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@PathVariable long resultId,
@Valid @RequestBody InboundMeasurementRequests.CorrectWeightResultRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.correctWeightResult(
tenantId, inboundId, resultId, user.userId(), user.username(), request));
}
@BusinessAudit(event = "INBOUND_WEIGHT_OCR_CONFIRM", resourceType = "INBOUND_WEIGHT_RESULT",
resourceId = "#resultId", description = "确认 OCR 重量并累加实际总毛重")
@Operation(summary = "确认 OCR 重量", description = "统一换算为 KG 并重算所有已确认图片的总重量。")
@PostMapping("/weight-results/{resultId}/confirm")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:confirm')")
public ApiResponse<InboundMeasurementResponses.WeightResult> confirmWeightResult(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@PathVariable long resultId,
@Valid @RequestBody InboundMeasurementRequests.ConfirmWeightResultRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.confirmWeightResult(
tenantId, inboundId, resultId, user.userId(), user.username(), request));
}
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
if (tenantId <= 0 || user.userId() == null || user.userId() <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户和操作人");
}
}
}
@@ -0,0 +1,122 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import com.baiyue.firstleg.service.MeasurementReviewService;
import com.baiyue.firstleg.service.AutomaticInboundCompletionService;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
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/inbounds")
public class PlatformInboundMeasurementReviewController {
private final MeasurementReviewService service;
private final AutomaticInboundCompletionService automaticCompletion;
public PlatformInboundMeasurementReviewController(MeasurementReviewService service,
AutomaticInboundCompletionService automaticCompletion) {
this.service = service;
this.automaticCompletion = automaticCompletion;
}
@BusinessAudit(event = "INBOUND_MEASURE_APPROVE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "测量审核通过")
@Operation(summary = "测量审核通过",
description = "客服审核通过后自动计费、发布费用给客户并完成入库;客户无需确认费用。")
@PostMapping("/{inboundId}/measurements/approve")
@PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')")
public ApiResponse<FeeSnapshotResponse> approve(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@Parameter(required = true, description = "同键同请求重试返回首次成功结果")
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody MeasurementReviewRequest request) {
validateScope(user, tenantId);
service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
return ApiResponse.success(automaticCompletion.completeApproved(
tenantId, inboundId, user.userId(), user.username(), request.measurementId(),
request.expectedVersion() + 1));
}
@BusinessAudit(event = "INBOUND_MEASURE_REJECT", resourceType = "INBOUND",
resourceId = "#inboundId", description = "测量审核驳回")
@Operation(summary = "测量审核驳回",
description = "驳回原因必填,只退回测量数据修改;已确认的托盘和库位占用保持不动。")
@PostMapping("/{inboundId}/measurements/reject")
@PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')")
public ApiResponse<Void> reject(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody MeasurementReviewRequest request) {
validateScope(user, tenantId);
service.reject(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
return ApiResponse.success(null);
}
@BusinessAudit(event = "INBOUND_MEASURE_EXCEPTION_RELEASE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "测量驳回异常放行")
@Operation(summary = "测量驳回异常放行",
description = "Nacos 开关开启、有异常放行权限且原因非空时,才可从 MEASURE_REJECTED 推进至 WAIT_PRICING。")
@PostMapping("/{inboundId}/measurements/exception-release")
@PreAuthorize("hasAuthority('firstleg:inbound:exception-release')")
public ApiResponse<Void> exceptionRelease(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody ExceptionReleaseRequest request) {
validateScope(user, tenantId);
service.exceptionRelease(
tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
return ApiResponse.success(null);
}
@Operation(summary = "查询测量审核历史")
@GetMapping("/{inboundId}/measurement-review-history")
@PreAuthorize("hasAuthority('firstleg:inbound:measurement-review-history')")
public ApiResponse<PageResult<MeasureReviewRecordEntity>> history(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@RequestParam(defaultValue = "1") @Min(1) long page,
@RequestParam(defaultValue = "20") @Min(1) @Max(200) long size) {
validateScope(user, tenantId);
return ApiResponse.success(service.history(tenantId, inboundId, page, size));
}
private static void validateScope(AuthenticatedUser user, long tenantId) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
if (tenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户");
}
}
}
@@ -0,0 +1,67 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundOrderDetail;
import com.baiyue.firstleg.dto.InboundOrderQuery;
import com.baiyue.firstleg.dto.InboundOrderSummary;
import com.baiyue.firstleg.service.InboundOrderQueryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
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.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 平台端入库单列表和详情只读接口。 */
@Tag(name = "平台入库单", description = "二期入库单列表、页签、筛选和只读聚合详情")
@RestController
@RequestMapping("/api/platform/inbounds")
public class PlatformInboundOrderController {
private final InboundOrderQueryService service;
public PlatformInboundOrderController(InboundOrderQueryService service) {
this.service = service;
}
@Operation(summary = "分页查询入库单", description = "按目标租户和二期状态页签分页查询入库单快照。")
@GetMapping
@PreAuthorize("hasAuthority('platform:firstleg:inbound:list')")
public ApiResponse<PageResult<InboundOrderSummary>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@Valid InboundOrderQuery query) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.page(tenantId, query));
}
@Operation(summary = "查询入库单详情", description = "查询入库单、运输、托盘库位、文件、备注和状态历史快照。")
@GetMapping("/{inboundId}")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:view')")
public ApiResponse<InboundOrderDetail> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId) {
requirePlatformTenant(user, tenantId);
if (inboundId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "入库单ID必须大于零");
}
return ApiResponse.success(service.detail(tenantId, inboundId));
}
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
if (tenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户");
}
}
}
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.domain;
import java.util.List;
/** 入库列表页签及其流程状态映射。 */
public enum InboundListTab {
WAIT_MEASURE(List.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED")),
WAIT_REVIEW(List.of("MEASURE_PENDING")),
WAIT_FEE_SEND(List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING")),
INBOUNDED(List.of("INBOUNDED"));
private final List<String> processStatuses;
InboundListTab(List<String> processStatuses) {
this.processStatuses = processStatuses;
}
public List<String> processStatuses() {
return processStatuses;
}
}
@@ -0,0 +1,14 @@
package com.baiyue.firstleg.domain;
import java.util.Set;
/** 二期入库主流程状态,不包含原型中的“待客户确认”。 */
public final class InboundProcessStatus {
public static final Set<String> VALUES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", "VOIDED"
);
private InboundProcessStatus() {
}
}
@@ -0,0 +1,15 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
/** 测量驳回后的异常放行请求。 */
public record ExceptionReleaseRequest(
@NotNull @Positive Long measurementId,
@NotNull @PositiveOrZero Integer expectedVersion,
@NotBlank @Size(max = 1000) String reason
) {
}
@@ -0,0 +1,77 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
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.LocalDateTime;
/** 二期 FBA 计费请求与只读快照响应。 */
public final class FbaPricingDtos {
private FbaPricingDtos() {}
public record CustomerRuleVersionRequest(
@NotBlank @Size(max = 50) String ruleCode,
@NotNull @Min(1) Long customerId,
@NotBlank @Size(max = 128) String customerName,
@NotBlank @Size(max = 32) String businessType,
@NotBlank @Pattern(regexp = "KG|CBM") String chargeMode,
@NotBlank @Pattern(regexp = "TAX_INCLUDED|TAX_EXCLUDED") String taxMode,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal conversionFactor,
@NotNull @Min(0) @Max(4) Integer roundingScale,
@NotBlank @Pattern(regexp = "UP|DOWN|CEILING|FLOOR|HALF_UP|HALF_DOWN|HALF_EVEN") String roundingMode,
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
@NotNull LocalDateTime effectiveFrom,
LocalDateTime effectiveTo,
@Size(max = 500) String remark
) {}
public record CarrierRateVersionRequest(
@NotBlank @Size(max = 50) String rateCode,
@NotNull @Min(1) Long originWarehouseId,
@NotBlank @Size(max = 128) String originWarehouseName,
@NotBlank @Size(max = 32) String businessType,
@NotBlank @Size(max = 64) String channelCode,
@NotNull @Min(1) Long carrierId,
@NotBlank @Size(max = 128) String carrierName,
@NotBlank @Size(max = 32) String destinationCode,
@NotBlank @Pattern(regexp = "KG|CBM") String chargeUnit,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal unitPrice,
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
@NotNull @DecimalMin("0") BigDecimal additionalFee,
@NotNull LocalDateTime effectiveFrom,
LocalDateTime effectiveTo,
@Size(max = 500) String remark
) {}
public record CalculateRequest(LocalDateTime pricingAt) {}
public record AdjustFeeRequest(
@NotNull @DecimalMin("0.00") BigDecimal subtotalAmount,
@NotNull @DecimalMin("0.00") BigDecimal additionalAmount,
@NotBlank @Size(max = 500) String reason
) {}
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,
BigDecimal conversionFactor, Integer roundingScale, String roundingMode,
BigDecimal actualWeightKg, BigDecimal actualVolumeCbm,
BigDecimal convertedQuantity, BigDecimal chargeableQuantity, String chargeUnit,
BigDecimal unitPrice, BigDecimal subtotalAmount, BigDecimal additionalAmount,
BigDecimal totalAmount, String currency,
LocalDateTime rateEffectiveFrom, LocalDateTime rateEffectiveTo,
LocalDateTime calculatedAt, LocalDateTime confirmedAt, LocalDateTime sentAt,
String nextProcessStatus
) {}
}
@@ -0,0 +1,82 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.List;
/**
* 平台端入库测量写请求。计算字段只由服务端生成,避免前端篡改汇总结果。
*/
public final class InboundMeasurementRequests {
private InboundMeasurementRequests() {
}
public record SaveDraftRequest(
Long measurementId,
@NotNull @PositiveOrZero Integer version,
@Size(max = 64) String originalDocumentNo,
@DecimalMin("0.000") @Digits(integer = 15, fraction = 3) BigDecimal originalGrossWeightKg,
@DecimalMin("0.0000") @Digits(integer = 14, fraction = 4) BigDecimal originalVolumeCbm,
@Size(max = 255) String actualGoodsName,
@NotEmpty @Size(max = 500) List<@Valid ItemRequest> items
) {
}
public record ItemRequest(
@NotBlank @Size(max = 32) String itemNo,
@NotBlank @Size(max = 64) String boxSpecification,
@NotNull @Positive @Max(1000000) Integer packageCount,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal lengthCm,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal widthCm,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal heightCm,
@NotNull @DecimalMin("0.001") @Digits(integer = 15, fraction = 3) BigDecimal unitWeightKg,
@Positive Long intelligentImageFileId,
@Size(max = 500) String overLimitWarning,
@DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerLengthCm,
@DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerWidthCm,
@DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerHeightCm,
@Size(max = 500) String remark
) {
}
public record SubmitRequest(
@NotNull @Positive Long measurementId,
@NotNull @PositiveOrZero Integer version
) {
}
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
) {
}
public record CorrectWeightResultRequest(
@NotNull @PositiveOrZero Integer version,
@NotNull @DecimalMin("0.001") @Digits(integer = 15, fraction = 3) BigDecimal adjustedWeightValue,
@NotBlank @Size(max = 8) String adjustedWeightUnit,
@NotBlank @Size(max = 500) String adjustmentReason
) {
}
public record ConfirmWeightResultRequest(
@NotNull @PositiveOrZero Integer version
) {
}
}
@@ -0,0 +1,101 @@
package com.baiyue.firstleg.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 入库测量详情响应,覆盖原单、实测明细、审核快照和 OCR 重量维护字段。
*/
public final class InboundMeasurementResponses {
private InboundMeasurementResponses() {
}
public record Detail(
Long inboundId,
String inboundCode,
String companyName,
String channelCode,
String channelName,
String processStatus,
String measureReviewStatus,
@Schema(description = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘",
allowableValues = "STANDARD", example = "STANDARD")
String recommendedPalletType,
String reviewReason,
Long reviewerId,
String reviewerName,
Long measurementId,
String sourceType,
String measurementStatus,
Integer version,
String originalDocumentNo,
BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm,
Integer actualPackageCount,
BigDecimal actualGrossWeightKg,
BigDecimal actualVolumeCbm,
BigDecimal volumeWeightKg,
String actualGoodsName,
List<Item> items,
List<WeightResult> weightResults
) {
}
public record Item(
Long id,
String itemNo,
String boxSpecification,
Integer packageCount,
BigDecimal lengthCm,
BigDecimal widthCm,
BigDecimal heightCm,
BigDecimal girthCm,
BigDecimal unitWeightKg,
BigDecimal unitVolumeCbm,
BigDecimal totalWeightKg,
BigDecimal totalVolumeCbm,
BigDecimal volumeWeightKg,
Long intelligentImageFileId,
String overLimitWarning,
BigDecimal outerLengthCm,
BigDecimal outerWidthCm,
BigDecimal outerHeightCm,
String remark
) {
}
public record WeightResult(
Long id,
Long fileId,
String ocrProvider,
String ocrRawText,
BigDecimal ocrWeightValue,
String ocrWeightUnit,
BigDecimal confidence,
BigDecimal adjustedWeightValue,
String adjustedWeightUnit,
BigDecimal finalWeightKg,
String recognitionStatus,
String adjustmentReason,
Long adjustedBy,
LocalDateTime adjustedAt,
Long confirmedBy,
LocalDateTime confirmedAt,
Integer version
) {
}
public record DraftSaved(Long measurementId, Integer version, String status) {
}
public record MeasurementSubmitted(Long measurementId, Integer version, String processStatus,
String measureReviewStatus,
@Schema(description = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘",
allowableValues = "STANDARD", example = "STANDARD")
String recommendedPalletType,
Long feeId, String feeNo, String feeStatus) {
}
}
@@ -0,0 +1,35 @@
package com.baiyue.firstleg.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** 入库单只读聚合详情。 */
public record InboundOrderDetail(
InboundOrderSummary summary,
String publicRemark,
List<PalletLocationSummary> palletLocations,
List<FileSummary> files,
List<InternalNoteSummary> internalNotes,
List<StatusHistorySummary> statusHistory
) {
public record PalletLocationSummary(
Integer allocationNo, Long warehouseId, Long palletId, String palletCode,
BigDecimal palletCapacityCbm, BigDecimal allocatedVolumeCbm, Integer allocatedPackageCount,
Long locationId, String locationCode, String reservationNo, String allocationStatus) {
}
public record FileSummary(
Long fileId, String fileType, String fileNameSnapshot, String visibility,
Integer fileVersion, Long uploadedBy, LocalDateTime uploadedAt) {
}
public record InternalNoteSummary(
Long id, String content, Long createdBy, String createdByName, LocalDateTime createdAt) {
}
public record StatusHistorySummary(
String fromStatus, String toStatus, String action, String reason,
Long operatorId, String operatorName, LocalDateTime occurredAt) {
}
}
@@ -0,0 +1,42 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.domain.InboundListTab;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
/** 入库单分页筛选条件。 */
@Schema(name = "InboundOrderQuery", description = "入库单列表筛选和页签条件")
public record InboundOrderQuery(
@Size(max = 40) String inboundCode,
@Size(max = 32) String orderNo,
@Size(max = 24) String processStatus,
@Size(max = 24) String orderStatus,
@Size(max = 24) String measureReviewStatus,
@Size(max = 24) String feeStatus,
@Size(max = 24) String feeReviewStatus,
InboundListTab tab,
LocalDateTime expectedArrivalFrom,
LocalDateTime expectedArrivalTo,
Long customerId,
@Size(max = 128) String customerName,
Long companyId,
Long warehouseId,
Long deliveryWarehouseId,
@Size(max = 32) String destinationCode,
Boolean holdFlag,
@Min(1) long page,
@Min(1) @Max(200) long size
) {
public InboundOrderQuery {
if (page == 0) {
page = 1;
}
if (size == 0) {
size = 20;
}
}
}
@@ -0,0 +1,60 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 入库列表行,所有名称和运输信息均为业务快照。 */
public record InboundOrderSummary(
Long id, Long orderId, String orderNo, String inboundCode,
String processStatus, String orderStatusSnapshot, String measureReviewStatus,
String feeStatus, String feeReviewStatus, String notificationStatus,
LocalDateTime expectedArrivalAt,
Long customerId, String customerName, Long companyId, String companyName,
Long warehouseId, String warehouseName,
Long deliveryWarehouseId, String deliveryWarehouseName,
String businessType, String channelCode, String channelName,
String taxMode, String containerMode, Boolean customsInspection,
String destinationType, Long destinationWarehouseId, String destinationCode, String destinationName,
Boolean holdFlag, String currentHoldReason,
Integer totalPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg,
BigDecimal actualVolumeCbm, BigDecimal volumeWeightKg,
BigDecimal chargeableWeightKg, BigDecimal chargeableVolumeCbm, Integer palletCount,
String locationCodes,
LocalDateTime measureCompletedAt, LocalDateTime inboundedAt,
String expressCompanyCode, String expressCompanyName, String expressTrackingNo,
String loadingStatus, String containerNo, String cabinetNo,
String vesselName, String voyageNo, LocalDateTime sailingAt, LocalDateTime portArrivalAt,
LocalDateTime createdAt, LocalDateTime updatedAt
) {
public static InboundOrderSummary from(
InboundOrderEntity order, InboundTransportSnapshotEntity transport, String locationCodes) {
return new InboundOrderSummary(
order.getId(), order.getOrderId(), order.getOrderNo(), order.getInboundCode(),
order.getProcessStatus(), order.getOrderStatusSnapshot(), order.getMeasureReviewStatus(),
order.getFeeStatus(), order.getFeeReviewStatus(), order.getNotificationStatus(),
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.getCustomsInspection(), order.getDestinationType(), order.getDestinationWarehouseId(),
order.getDestinationCode(), order.getDestinationName(), order.getHoldFlag(),
order.getCurrentHoldReason(), order.getTotalPackageCount(), order.getActualGrossWeightKg(),
order.getActualNetWeightKg(), order.getActualVolumeCbm(), order.getVolumeWeightKg(),
order.getChargeableWeightKg(), order.getChargeableVolumeCbm(), order.getPalletCount(),
locationCodes,
order.getMeasureCompletedAt(), order.getInboundedAt(), order.getExpressCompanyCode(),
order.getExpressCompanyName(), order.getExpressTrackingNo(),
transport == null ? null : transport.getLoadingStatus(),
transport == null ? null : transport.getContainerNo(),
transport == null ? null : transport.getCabinetNo(),
transport == null ? null : transport.getVesselName(),
transport == null ? null : transport.getVoyageNo(),
transport == null ? null : transport.getSailingAt(),
transport == null ? null : transport.getPortArrivalAt(),
order.getCreatedAt(), order.getUpdatedAt()
);
}
}
@@ -0,0 +1,46 @@
package com.baiyue.firstleg.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 测量领域统一写命令。后台 DTO 与第三方开放 API DTO 都应先适配为该命令。
* 该命令的持久化过程不改变入库单流程状态。
*/
public record MeasurementBatchCommand(
long tenantId,
long inboundId,
Long measurementId,
int expectedVersion,
String sourceType,
String sourceCode,
String externalEventNo,
String deviceCode,
String warehouseCode,
LocalDateTime measuredAt,
String rawPayloadJson,
String originalDocumentNo,
BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm,
String actualGoodsName,
long operatorId,
List<Item> items
) {
public record Item(
String itemNo,
String boxSpecification,
int packageCount,
BigDecimal lengthCm,
BigDecimal widthCm,
BigDecimal heightCm,
BigDecimal unitWeightKg,
Long intelligentImageFileId,
String overLimitWarning,
BigDecimal outerLengthCm,
BigDecimal outerWidthCm,
BigDecimal outerHeightCm,
String remark
) {
}
}
@@ -0,0 +1,14 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
/** 测量审核请求,expectedVersion 用于拒绝过期页面提交。 */
public record MeasurementReviewRequest(
@NotNull @Positive Long measurementId,
@NotNull @PositiveOrZero Integer expectedVersion,
@Size(max = 1000) String reason
) {
}
@@ -0,0 +1,7 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
public record MerchantInboundBillingResponse(InboundOrderDetail inbound,
FeeSnapshotResponse latestFee) {
}
@@ -0,0 +1,65 @@
package com.baiyue.firstleg.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
/** 第三方长宽高测量开放接口 DTO。 */
public final class OpenApiMeasurementDtos {
private OpenApiMeasurementDtos() {
}
@Schema(name = "InboundMeasurementCallbackRequest", description = "按入仓码提交的第三方长宽高测量结果")
public record CallbackRequest(
@NotBlank @Size(max = 128)
@Pattern(regexp = "[A-Za-z0-9._:-]+")
@Schema(description = "第三方业务事件号,同一 appKey 下唯一", example = "M202608010001")
String eventNo,
@NotBlank @Size(max = 50)
@Schema(description = "已绑定到应用和租户的仓库编码", example = "CN-YW-01")
String warehouseCode,
@NotBlank @Size(max = 40)
@Schema(description = "入仓码,不接受内部数据库主键", example = "RK202608010001")
String inboundCode,
@NotBlank @Size(max = 64)
@Schema(description = "测量设备编码", example = "DEVICE-01")
String deviceCode,
@NotNull
@Schema(description = "设备测量时间,必须包含时区", example = "2026-08-01T10:30:00+08:00")
OffsetDateTime measuredAt,
@NotEmpty @Size(max = 500) List<@Valid Item> items) {
}
@Schema(name = "InboundMeasurementItem", description = "相同箱规的测量明细")
public record Item(
@NotBlank @Size(max = 32)
@Pattern(regexp = "[A-Za-z0-9._:-]+") String itemNo,
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal lengthCm,
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal widthCm,
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal heightCm,
@NotNull @Min(1) @Max(1000000) Integer quantity,
@Size(max = 2048) String sourceImageUrl,
@NotBlank @Size(max = 64) String boxSpecification) {
}
@Schema(name = "InboundMeasurementEventResponse", description = "第三方测量事件处理结果")
public record EventResponse(
String eventNo,
String inboundCode,
boolean accepted,
String status,
OffsetDateTime receivedAt) {
}
}
@@ -0,0 +1,38 @@
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;
import java.time.LocalDateTime;
/** 船司 FBA 目的仓价格的不可变版本。 */
@Getter
@Setter
@TableName("fl_carrier_fba_rate")
public class CarrierFbaRateEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String rateCode;
private Integer rateVersion;
private Long originWarehouseId;
private String originWarehouseName;
private String businessType;
private String channelCode;
private Long carrierId;
private String carrierName;
private String destinationCode;
private String chargeUnit;
private BigDecimal unitPrice;
private String currency;
private BigDecimal additionalFee;
private LocalDateTime effectiveFrom;
private LocalDateTime effectiveTo;
private String status;
private String remark;
}
@@ -0,0 +1,36 @@
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;
import java.time.LocalDateTime;
/** 客户 FBA 计费规则的不可变版本。 */
@Getter
@Setter
@TableName("fl_customer_charge_rule")
public class CustomerChargeRuleEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String ruleCode;
private Integer ruleVersion;
private Long customerId;
private String customerName;
private String businessType;
private String chargeMode;
private String taxMode;
private BigDecimal conversionFactor;
private Integer roundingScale;
private String roundingMode;
private String currency;
private LocalDateTime effectiveFrom;
private LocalDateTime effectiveTo;
private String status;
private String remark;
}
@@ -0,0 +1,67 @@
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_fee")
public class InboundFeeEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private String feeNo;
private Integer feeVersion;
private Long chargeRuleId;
private Integer chargeRuleVersion;
private Long carrierRateId;
private Integer carrierRateVersion;
private String ruleCode;
private Long customerId;
private String customerName;
private String businessType;
private String taxMode;
private Integer roundingScale;
private String roundingMode;
private LocalDateTime ruleEffectiveFrom;
private LocalDateTime ruleEffectiveTo;
private String rateCode;
private Long originWarehouseId;
private String originWarehouseName;
private String channelCode;
private Long carrierId;
private String carrierName;
private String destinationCode;
private LocalDateTime rateEffectiveFrom;
private LocalDateTime rateEffectiveTo;
private String chargeMode;
private BigDecimal conversionFactor;
private BigDecimal actualWeightKg;
private BigDecimal actualVolumeCbm;
private BigDecimal convertedQuantity;
private BigDecimal chargeableQuantity;
private String chargeUnit;
private String currency;
private BigDecimal subtotalAmount;
private BigDecimal additionalAmount;
private BigDecimal totalAmount;
private String feeStatus;
private String reviewStatus;
private Boolean exceptionRelease;
private Long calculatedBy;
private LocalDateTime calculatedAt;
private Long confirmedBy;
private LocalDateTime confirmedAt;
private Long sentBy;
private LocalDateTime sentAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,32 @@
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_fee_item")
public class InboundFeeItemEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long feeId;
private Integer itemNo;
private String feeCode;
private String feeName;
private BigDecimal quantity;
private String unit;
private BigDecimal unitPrice;
private BigDecimal amount;
private String currency;
private Boolean automaticFlag;
private String remark;
private LocalDateTime createdAt;
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 入库单与文件中心对象的分类关系。 */
@Getter
@Setter
@TableName("fl_inbound_file")
public class InboundFileEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Long fileId;
private String fileType;
private String fileNameSnapshot;
private String visibility;
private Integer fileVersion;
private Long uploadedBy;
private LocalDateTime uploadedAt;
@TableLogic
private Boolean deleted;
}
@@ -0,0 +1,29 @@
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.time.LocalDateTime;
/** 入库写操作幂等记录。 */
@Getter
@Setter
@TableName("fl_inbound_idempotency")
public class InboundIdempotencyEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String operation;
private Long operatorId;
private Long inboundId;
private String idempotencyKey;
private String requestHash;
private String status;
private String resultJson;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,24 @@
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.time.LocalDateTime;
/** 客服内部备注历史,仅平台详情可见。 */
@Getter
@Setter
@TableName("fl_inbound_internal_note")
public class InboundInternalNoteEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private String content;
private Long createdBy;
private String createdByName;
private LocalDateTime createdAt;
}
@@ -0,0 +1,43 @@
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.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 后台和第三方共同使用的测量批次。 */
@Getter
@Setter
@TableName("fl_inbound_measurement")
public class InboundMeasurementEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private String sourceType;
private String sourceCode;
private String externalEventNo;
private String deviceCode;
private String warehouseCode;
private LocalDateTime measuredAt;
private String status;
private String rawPayloadJson;
private String originalDocumentNo;
private BigDecimal originalGrossWeightKg;
private BigDecimal originalVolumeCbm;
private String actualGoodsName;
private Integer totalPackageCount;
private BigDecimal totalVolumeRawCbm;
private BigDecimal totalVolumeCbm;
private BigDecimal volumeWeightKg;
private Long createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@Version
private Integer version;
}
@@ -0,0 +1,43 @@
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_measurement_item")
public class InboundMeasurementItemEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long measurementId;
private String itemNo;
private String boxSpecification;
private Integer packageCount;
private BigDecimal lengthCm;
private BigDecimal widthCm;
private BigDecimal heightCm;
private BigDecimal girthCm;
private BigDecimal unitWeightKg;
private BigDecimal unitVolumeCbm;
private BigDecimal volumeRawCbm;
private BigDecimal volumeCbm;
private BigDecimal totalWeightKg;
private BigDecimal totalVolumeCbm;
private BigDecimal volumeWeightKg;
private String calculationRuleVersion;
private Long sourceImageFileId;
private String overLimitWarning;
private BigDecimal outerLengthCm;
private BigDecimal outerWidthCm;
private BigDecimal outerHeightCm;
private String remark;
private LocalDateTime createdAt;
}
@@ -0,0 +1,67 @@
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;
import java.time.LocalDateTime;
/** 二期入库单聚合根及列表查询快照。 */
@Getter
@Setter
@TableName("fl_inbound_order")
public class InboundOrderEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long taskId;
private Long orderId;
private String orderNo;
private String inboundCode;
private String processStatus;
private String orderStatusSnapshot;
private String measureReviewStatus;
private String feeStatus;
private String feeReviewStatus;
private String notificationStatus;
private LocalDateTime expectedArrivalAt;
private Long customerId;
private String customerName;
private Long companyId;
private String companyName;
private Long warehouseId;
private String warehouseName;
private Long deliveryWarehouseId;
private String deliveryWarehouseName;
private String businessType;
private String channelCode;
private String channelName;
private String taxMode;
private String containerMode;
private Boolean customsInspection;
private String destinationType;
private Long destinationWarehouseId;
private String destinationCode;
private String destinationName;
private Boolean holdFlag;
private String currentHoldReason;
private Integer totalPackageCount;
private BigDecimal actualGrossWeightKg;
private BigDecimal chargeableWeightKg;
private BigDecimal actualVolumeCbm;
private BigDecimal chargeableVolumeCbm;
private Integer palletCount;
private BigDecimal volumeWeightKg;
private BigDecimal actualNetWeightKg;
private LocalDateTime measureCompletedAt;
private LocalDateTime inboundedAt;
private String expressCompanyCode;
private String expressCompanyName;
private String expressTrackingNo;
private String publicRemark;
private String createdByName;
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
/** 开放接口只读使用的入库单最小投影,禁止通过该实体更新主单。 */
@Getter
@Setter
@TableName("fl_inbound_order")
public class InboundOrderLookupEntity {
@TableId
private Long id;
private Long tenantId;
private String inboundCode;
private Long warehouseId;
private String processStatus;
private Integer deleted;
}
@@ -0,0 +1,26 @@
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.time.LocalDateTime;
/** 入库完成事件 Outbox 记录。 */
@Getter
@Setter
@TableName("fl_inbound_outbox")
public class InboundOutboxEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String aggregateType;
private Long aggregateId;
private String eventType;
private String payloadJson;
private String publishStatus;
private Integer retryCount;
private LocalDateTime createdAt;
}
@@ -0,0 +1,34 @@
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_pallet_allocation")
public class InboundPalletAllocationEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Integer allocationNo;
private Long warehouseId;
private Long palletId;
private String palletCode;
private BigDecimal palletCapacityCbm;
private BigDecimal allocatedVolumeCbm;
private Integer allocatedPackageCount;
private Long locationId;
private String locationCode;
private String reservationNo;
private String allocationStatus;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,39 @@
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_inbound_order")
public class InboundPricingEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String inboundCode;
private String processStatus;
private String measureReviewStatus;
private String feeStatus;
private Long customerId;
private String customerName;
private Long warehouseId;
private String warehouseName;
private String businessType;
private String channelCode;
private Long carrierId;
private String carrierName;
private String taxMode;
private String destinationType;
private String destinationCode;
private BigDecimal actualGrossWeightKg;
private BigDecimal actualVolumeCbm;
private BigDecimal chargeableWeightKg;
private BigDecimal chargeableVolumeCbm;
}
@@ -0,0 +1,30 @@
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.time.LocalDateTime;
/** 费用试算、确认和发送操作的持久化幂等记录。 */
@Getter
@Setter
@TableName("fl_inbound_idempotency")
public class InboundPricingIdempotencyEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String operation;
private Long operatorId;
private Long inboundId;
private String idempotencyKey;
private String requestHash;
private String status;
private String resultJson;
private Long resultResourceId;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,27 @@
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.time.LocalDateTime;
/** 入库流程状态不可变历史。 */
@Getter
@Setter
@TableName("fl_inbound_status_history")
public class InboundStatusHistoryEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private String fromStatus;
private String toStatus;
private String action;
private String reason;
private Long operatorId;
private String operatorName;
private LocalDateTime occurredAt;
}
@@ -0,0 +1,30 @@
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.time.LocalDateTime;
/** 关联运输信息只读快照。 */
@Getter
@Setter
@TableName("fl_inbound_transport_snapshot")
public class InboundTransportSnapshotEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private String loadingStatus;
private String containerNo;
private String cabinetNo;
private String vesselName;
private String voyageNo;
private LocalDateTime sailingAt;
private LocalDateTime portArrivalAt;
private LocalDateTime sourceUpdatedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,37 @@
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;
import java.time.LocalDateTime;
/** 称重图片 OCR 原值、人工修正值和最终确认重量。 */
@Getter
@Setter
@TableName("fl_inbound_weight_result")
public class InboundWeightResultEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Long fileId;
private String ocrProvider;
private String ocrRawText;
private BigDecimal ocrWeightValue;
private String ocrWeightUnit;
private BigDecimal confidence;
private BigDecimal adjustedWeightValue;
private String adjustedWeightUnit;
private BigDecimal finalWeightKg;
private String recognitionStatus;
private String adjustmentReason;
private Long adjustedBy;
private LocalDateTime adjustedAt;
private Long confirmedBy;
private LocalDateTime confirmedAt;
}
@@ -0,0 +1,33 @@
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;
/** OCR 重量结果每次人工修正和确认的不可变版本。 */
@Getter
@Setter
@TableName("fl_inbound_weight_revision")
public class InboundWeightRevisionEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Long weightResultId;
private Integer revisionNo;
private String action;
private BigDecimal beforeWeightValue;
private String beforeWeightUnit;
private BigDecimal afterWeightValue;
private String afterWeightUnit;
private BigDecimal finalWeightKg;
private String reason;
private Long operatorId;
private String operatorName;
private LocalDateTime operatedAt;
}
@@ -0,0 +1,29 @@
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.time.LocalDateTime;
/** 测量审核、驳回和异常放行不可变历史。 */
@Getter
@Setter
@TableName("fl_measure_review_record")
public class MeasureReviewRecordEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Long measurementId;
private String reviewResult;
private String reviewReason;
private Boolean exceptionRelease;
private String releaseReason;
private Boolean allowRejectedConfig;
private Long reviewerId;
private String reviewerName;
private LocalDateTime reviewedAt;
}
@@ -0,0 +1,36 @@
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.time.LocalDateTime;
/** 第三方测量 API 每次调用的原始报文和处理结果留档。 */
@Getter
@Setter
@TableName("fl_third_party_call_log")
public class ThirdPartyCallLogEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String appKey;
private String signatureSubject;
private String requestMethod;
private String requestPath;
private String requestTimestamp;
private String nonce;
private String idempotencyKey;
private Integer idempotencyAnchor;
private String signatureValue;
private String requestHash;
private String rawPayload;
private String processingStatus;
private String resultCode;
private String resultMessage;
private String resultJson;
private LocalDateTime requestedAt;
private LocalDateTime processedAt;
}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
public interface CarrierFbaRateMapper extends BaseMapper<CarrierFbaRateEntity> {}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
public interface CustomerChargeRuleMapper extends BaseMapper<CustomerChargeRuleEntity> {}
@@ -0,0 +1,16 @@
package com.baiyue.firstleg.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/** 只检查任务5已准备好的托盘/库位引用,不在头程服务实现仓库 CRUD。 */
@Mapper
public interface InboundAllocationQueryMapper {
@Select("""
SELECT COUNT(*) FROM fl_inbound_pallet_allocation
WHERE tenant_id = #{tenantId} AND inbound_id = #{inboundId}
AND allocation_status IN ('RESERVED', 'CONFIRMED')
""")
long countPrepared(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
public interface InboundFeeItemMapper extends BaseMapper<InboundFeeItemEntity> {}
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface InboundFeeMapper extends BaseMapper<InboundFeeEntity> {
@Update("""
UPDATE fl_inbound_fee
SET fee_status = 'SENT', sent_by = #{operatorId}, sent_at = CURRENT_TIMESTAMP(3)
WHERE tenant_id = #{tenantId} AND inbound_id = #{inboundId} AND id = #{feeId}
AND fee_status = 'CONFIRMED'
""")
int markSent(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("feeId") long feeId, @Param("operatorId") long operatorId);
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundFileEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundFileMapper extends BaseMapper<InboundFileEntity> {
}
@@ -0,0 +1,17 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundIdempotencyEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface InboundIdempotencyMapper extends BaseMapper<InboundIdempotencyEntity> {
@Update("""
UPDATE fl_inbound_idempotency
SET status = 'SUCCESS', result_json = #{resultJson}, completed_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id} AND status = 'PROCESSING'
""")
int complete(@Param("id") long id, @Param("resultJson") String resultJson);
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundInternalNoteEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundInternalNoteMapper extends BaseMapper<InboundInternalNoteEntity> {
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundMeasurementItemMapper extends BaseMapper<InboundMeasurementItemEntity> {
}
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
/** 测量批次 Mapper。 */
@Mapper
public interface InboundMeasurementMapper extends BaseMapper<InboundMeasurementEntity> {
@Update("""
UPDATE fl_inbound_measurement SET status = #{toStatus}, updated_at = CURRENT_TIMESTAMP(3)
WHERE tenant_id = #{tenantId} AND inbound_id = #{inboundId} AND id = #{measurementId}
AND status = 'SUBMITTED'
""")
int reviewSubmitted(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("measurementId") long measurementId, @Param("toStatus") String toStatus);
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundOrderLookupEntity;
/** 入库单只读 Mapper,开放接口不得在此增加 update 方法。 */
public interface InboundOrderLookupMapper extends BaseMapper<InboundOrderLookupEntity> {
}
@@ -0,0 +1,80 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
/** 入库聚合根条件更新 Mapper,所有推进均同时校验租户、前置状态和版本。 */
@Mapper
public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
@Update("""
UPDATE fl_inbound_order
SET process_status = 'WAIT_PRICING', measure_review_status = 'APPROVED',
updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'MEASURE_PENDING' AND measure_review_status = 'PENDING'
AND version = #{expectedVersion}
""")
int approveMeasurement(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_inbound_order
SET process_status = 'MEASURE_REJECTED', measure_review_status = 'REJECTED',
updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'MEASURE_PENDING' AND measure_review_status = 'PENDING'
AND version = #{expectedVersion}
""")
int rejectMeasurement(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_inbound_order
SET process_status = 'WAIT_PRICING', updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'MEASURE_REJECTED' AND measure_review_status = 'REJECTED'
AND version = #{expectedVersion}
""")
int releaseRejectedMeasurement(@Param("tenantId") long tenantId,
@Param("inboundId") long inboundId,
@Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_inbound_order
SET process_status = 'INBOUNDING', updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'FEE_CONFIRMING' AND fee_status = 'CONFIRMED'
AND version = #{expectedVersion}
""")
int beginInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_inbound_order
SET process_status = 'INBOUNDED', fee_status = 'SENT', notification_status = 'PENDING',
inbounded_at = CURRENT_TIMESTAMP(3), updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'INBOUNDING' AND fee_status = 'CONFIRMED'
AND version = #{expectedVersion}
""")
int completeInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_inbound_order
SET process_status = 'FEE_CONFIRMING', updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'INBOUNDING' AND version = #{expectedVersion}
""")
int rollbackInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundOutboxMapper extends BaseMapper<InboundOutboxEntity> {
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundPalletAllocationMapper extends BaseMapper<InboundPalletAllocationEntity> {
}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
public interface InboundPricingIdempotencyMapper extends BaseMapper<InboundPricingIdempotencyEntity> {}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundPricingEntity;
public interface InboundPricingMapper extends BaseMapper<InboundPricingEntity> {}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundStatusHistoryMapper extends BaseMapper<InboundStatusHistoryEntity> {
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundTransportSnapshotMapper extends BaseMapper<InboundTransportSnapshotEntity> {
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundWeightResultMapper extends BaseMapper<InboundWeightResultEntity> {
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundWeightRevisionMapper extends BaseMapper<InboundWeightRevisionEntity> {
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MeasureReviewRecordMapper extends BaseMapper<MeasureReviewRecordEntity> {
}
@@ -0,0 +1,7 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.ThirdPartyCallLogEntity;
public interface ThirdPartyCallLogMapper extends BaseMapper<ThirdPartyCallLogEntity> {
}
@@ -0,0 +1,266 @@
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.CustomerChargeRuleEntity;
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.mapper.CarrierFbaRateMapper;
import com.baiyue.firstleg.mapper.CustomerChargeRuleMapper;
import com.baiyue.firstleg.mapper.InboundFeeItemMapper;
import com.baiyue.firstleg.mapper.InboundFeeMapper;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.baiyue.firstleg.mapper.InboundPricingIdempotencyMapper;
import com.baiyue.firstleg.mapper.InboundPricingMapper;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
/** FBA 计费仓储,所有业务查询都强制带租户条件。 */
@Repository
public class FbaPricingRepository {
private final CustomerChargeRuleMapper rules;
private final CarrierFbaRateMapper rates;
private final InboundPricingMapper inbounds;
private final InboundFeeMapper fees;
private final InboundFeeItemMapper items;
private final InboundPricingIdempotencyMapper idempotencies;
private final InboundOutboxMapper outbox;
public FbaPricingRepository(CustomerChargeRuleMapper rules, CarrierFbaRateMapper rates,
InboundPricingMapper inbounds, InboundFeeMapper fees,
InboundFeeItemMapper items,
InboundPricingIdempotencyMapper idempotencies,
InboundOutboxMapper outbox) {
this.rules = rules;
this.rates = rates;
this.inbounds = inbounds;
this.fees = fees;
this.items = items;
this.idempotencies = idempotencies;
this.outbox = outbox;
}
public void insertRule(CustomerChargeRuleEntity rule) { rules.insert(rule); }
public void insertRate(CarrierFbaRateEntity rate) { rates.insert(rate); }
public CustomerChargeRuleEntity findRule(long tenantId, long id) {
return rules.selectOne(new LambdaQueryWrapper<CustomerChargeRuleEntity>()
.eq(CustomerChargeRuleEntity::getTenantId, tenantId)
.eq(CustomerChargeRuleEntity::getId, id));
}
public CarrierFbaRateEntity findRate(long tenantId, long id) {
return rates.selectOne(new LambdaQueryWrapper<CarrierFbaRateEntity>()
.eq(CarrierFbaRateEntity::getTenantId, tenantId)
.eq(CarrierFbaRateEntity::getId, id));
}
public List<CustomerChargeRuleEntity> listRules(long tenantId, String ruleCode) {
return rules.selectList(new LambdaQueryWrapper<CustomerChargeRuleEntity>()
.eq(CustomerChargeRuleEntity::getTenantId, tenantId)
.eq(ruleCode != null && !ruleCode.isBlank(), CustomerChargeRuleEntity::getRuleCode, ruleCode)
.orderByAsc(CustomerChargeRuleEntity::getRuleCode)
.orderByDesc(CustomerChargeRuleEntity::getRuleVersion));
}
public List<CarrierFbaRateEntity> listRates(long tenantId, String rateCode) {
return rates.selectList(new LambdaQueryWrapper<CarrierFbaRateEntity>()
.eq(CarrierFbaRateEntity::getTenantId, tenantId)
.eq(rateCode != null && !rateCode.isBlank(), CarrierFbaRateEntity::getRateCode, rateCode)
.orderByAsc(CarrierFbaRateEntity::getRateCode)
.orderByDesc(CarrierFbaRateEntity::getRateVersion));
}
public int nextRuleVersion(long tenantId, String ruleCode) {
List<CustomerChargeRuleEntity> values = listRules(tenantId, ruleCode);
return values.isEmpty() ? 1 : values.get(0).getRuleVersion() + 1;
}
public int nextRateVersion(long tenantId, String rateCode) {
List<CarrierFbaRateEntity> values = listRates(tenantId, rateCode);
return values.isEmpty() ? 1 : values.get(0).getRateVersion() + 1;
}
public int retireRule(long tenantId, long id, long operatorId) {
return rules.update(null, new LambdaUpdateWrapper<CustomerChargeRuleEntity>()
.eq(CustomerChargeRuleEntity::getTenantId, tenantId)
.eq(CustomerChargeRuleEntity::getId, id)
.eq(CustomerChargeRuleEntity::getStatus, "ACTIVE")
.set(CustomerChargeRuleEntity::getStatus, "INACTIVE")
.set(CustomerChargeRuleEntity::getUpdatedBy, operatorId));
}
public int retireRate(long tenantId, long id, long operatorId) {
return rates.update(null, new LambdaUpdateWrapper<CarrierFbaRateEntity>()
.eq(CarrierFbaRateEntity::getTenantId, tenantId)
.eq(CarrierFbaRateEntity::getId, id)
.eq(CarrierFbaRateEntity::getStatus, "ACTIVE")
.set(CarrierFbaRateEntity::getStatus, "INACTIVE")
.set(CarrierFbaRateEntity::getUpdatedBy, operatorId));
}
public InboundPricingEntity findInbound(long tenantId, long inboundId) {
return inbounds.selectOne(new LambdaQueryWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
.eq(InboundPricingEntity::getId, inboundId));
}
public List<CustomerChargeRuleEntity> matchRules(InboundPricingEntity inbound, LocalDateTime at) {
return rules.selectList(new LambdaQueryWrapper<CustomerChargeRuleEntity>()
.eq(CustomerChargeRuleEntity::getTenantId, inbound.getTenantId())
.eq(CustomerChargeRuleEntity::getCustomerId, inbound.getCustomerId())
.eq(CustomerChargeRuleEntity::getBusinessType, inbound.getBusinessType())
.eq(CustomerChargeRuleEntity::getTaxMode, inbound.getTaxMode())
.eq(CustomerChargeRuleEntity::getStatus, "ACTIVE")
.le(CustomerChargeRuleEntity::getEffectiveFrom, at)
.and(w -> w.isNull(CustomerChargeRuleEntity::getEffectiveTo)
.or().gt(CustomerChargeRuleEntity::getEffectiveTo, at)));
}
public List<CarrierFbaRateEntity> matchRates(InboundPricingEntity inbound, String chargeUnit,
LocalDateTime at) {
return rates.selectList(new LambdaQueryWrapper<CarrierFbaRateEntity>()
.eq(CarrierFbaRateEntity::getTenantId, inbound.getTenantId())
.eq(CarrierFbaRateEntity::getOriginWarehouseId, inbound.getWarehouseId())
.eq(CarrierFbaRateEntity::getBusinessType, inbound.getBusinessType())
.eq(CarrierFbaRateEntity::getChannelCode, inbound.getChannelCode())
.eq(CarrierFbaRateEntity::getCarrierId, inbound.getCarrierId())
.eq(CarrierFbaRateEntity::getDestinationCode, inbound.getDestinationCode())
.eq(CarrierFbaRateEntity::getChargeUnit, chargeUnit)
.eq(CarrierFbaRateEntity::getStatus, "ACTIVE")
.le(CarrierFbaRateEntity::getEffectiveFrom, at)
.and(w -> w.isNull(CarrierFbaRateEntity::getEffectiveTo)
.or().gt(CarrierFbaRateEntity::getEffectiveTo, at)));
}
public int nextFeeVersion(long tenantId, long inboundId) {
InboundFeeEntity latest = fees.selectOne(new LambdaQueryWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId)
.eq(InboundFeeEntity::getInboundId, inboundId)
.orderByDesc(InboundFeeEntity::getFeeVersion).last("LIMIT 1"));
return latest == null ? 1 : latest.getFeeVersion() + 1;
}
public void supersedeCalculated(long tenantId, long inboundId) {
fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId)
.eq(InboundFeeEntity::getInboundId, inboundId)
.eq(InboundFeeEntity::getFeeStatus, "CALCULATED")
.set(InboundFeeEntity::getFeeStatus, "SUPERSEDED"));
}
public void insertFee(InboundFeeEntity fee) { fees.insert(fee); }
public void insertItem(InboundFeeItemEntity item) { items.insert(item); }
public InboundFeeEntity findFee(long tenantId, long feeId) {
return fees.selectOne(new LambdaQueryWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId).eq(InboundFeeEntity::getId, feeId));
}
public InboundFeeEntity findLatestFee(long tenantId, long inboundId) {
return fees.selectOne(new LambdaQueryWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId)
.eq(InboundFeeEntity::getInboundId, inboundId)
.orderByDesc(InboundFeeEntity::getFeeVersion).last("LIMIT 1"));
}
public InboundFeeItemEntity findBaseItem(long tenantId, long feeId) {
return items.selectOne(new LambdaQueryWrapper<InboundFeeItemEntity>()
.eq(InboundFeeItemEntity::getTenantId, tenantId)
.eq(InboundFeeItemEntity::getFeeId, feeId)
.eq(InboundFeeItemEntity::getFeeCode, "FBA_BASE"));
}
public List<InboundFeeItemEntity> listItems(long tenantId, long feeId) {
return items.selectList(new LambdaQueryWrapper<InboundFeeItemEntity>()
.eq(InboundFeeItemEntity::getTenantId, tenantId)
.eq(InboundFeeItemEntity::getFeeId, feeId)
.orderByAsc(InboundFeeItemEntity::getItemNo));
}
public int supersedeSent(long tenantId, long feeId) {
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId)
.eq(InboundFeeEntity::getId, feeId)
.eq(InboundFeeEntity::getFeeStatus, "SENT")
.set(InboundFeeEntity::getFeeStatus, "SUPERSEDED")
.set(InboundFeeEntity::getUpdatedAt, LocalDateTime.now()));
}
public int markInboundCalculated(long tenantId, long inboundId, String chargeMode,
java.math.BigDecimal chargeable) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
.eq(InboundPricingEntity::getId, inboundId)
.in(InboundPricingEntity::getProcessStatus, List.of("WAIT_PRICING", "FEE_CONFIRMING"))
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED"))
.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING")
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable);
else update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeable);
return inbounds.update(null, update);
}
public int confirmFee(long tenantId, long feeId, long operatorId, LocalDateTime now) {
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId).eq(InboundFeeEntity::getId, feeId)
.eq(InboundFeeEntity::getFeeStatus, "CALCULATED")
.set(InboundFeeEntity::getFeeStatus, "CONFIRMED")
.set(InboundFeeEntity::getConfirmedBy, operatorId)
.set(InboundFeeEntity::getConfirmedAt, now));
}
public int markInboundConfirmed(long tenantId, long inboundId) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
.eq(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING")
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
}
public int sendFee(long tenantId, long feeId, long operatorId, LocalDateTime now) {
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId).eq(InboundFeeEntity::getId, feeId)
.eq(InboundFeeEntity::getFeeStatus, "CONFIRMED")
.set(InboundFeeEntity::getFeeStatus, "SENT")
.set(InboundFeeEntity::getSentBy, operatorId)
.set(InboundFeeEntity::getSentAt, now));
}
public int markInboundWaitingPallet(long tenantId, long inboundId) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
.eq(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING")
.eq(InboundPricingEntity::getFeeStatus, "CONFIRMED")
.set(InboundPricingEntity::getProcessStatus, "WAIT_PALLET")
.set(InboundPricingEntity::getFeeStatus, "SENT"));
}
public InboundPricingIdempotencyEntity findIdempotency(long tenantId, long operatorId,
String operation, String key) {
return idempotencies.selectOne(new LambdaQueryWrapper<InboundPricingIdempotencyEntity>()
.eq(InboundPricingIdempotencyEntity::getTenantId, tenantId)
.eq(InboundPricingIdempotencyEntity::getOperatorId, operatorId)
.eq(InboundPricingIdempotencyEntity::getOperation, operation)
.eq(InboundPricingIdempotencyEntity::getIdempotencyKey, key));
}
public void insertIdempotency(InboundPricingIdempotencyEntity value) { idempotencies.insert(value); }
public int completeIdempotency(InboundPricingIdempotencyEntity value, long resourceId, LocalDateTime now) {
return idempotencies.update(null, new LambdaUpdateWrapper<InboundPricingIdempotencyEntity>()
.eq(InboundPricingIdempotencyEntity::getId, value.getId())
.eq(InboundPricingIdempotencyEntity::getStatus, "PROCESSING")
.set(InboundPricingIdempotencyEntity::getStatus, "COMPLETED")
.set(InboundPricingIdempotencyEntity::getResultResourceId, resourceId)
.set(InboundPricingIdempotencyEntity::getCompletedAt, now));
}
public void insertOutbox(InboundOutboxEntity event) { outbox.insert(event); }
}
@@ -0,0 +1,56 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderLookupEntity;
import com.baiyue.firstleg.mapper.InboundMeasurementItemMapper;
import com.baiyue.firstleg.mapper.InboundMeasurementMapper;
import com.baiyue.firstleg.mapper.InboundOrderLookupMapper;
import org.springframework.stereotype.Repository;
/** 第三方测量开放接口仓储,仅允许读取主单并写测量、明细和幂等记录。 */
@Repository
public class InboundMeasurementOpenApiRepository {
private final InboundOrderLookupMapper inboundOrders;
private final InboundMeasurementMapper measurements;
private final InboundMeasurementItemMapper items;
public InboundMeasurementOpenApiRepository(InboundOrderLookupMapper inboundOrders,
InboundMeasurementMapper measurements,
InboundMeasurementItemMapper items) {
this.inboundOrders = inboundOrders;
this.measurements = measurements;
this.items = items;
}
public InboundOrderLookupEntity findInbound(long tenantId, String inboundCode) {
return inboundOrders.selectOne(new LambdaQueryWrapper<InboundOrderLookupEntity>()
.eq(InboundOrderLookupEntity::getTenantId, tenantId)
.eq(InboundOrderLookupEntity::getInboundCode, inboundCode)
.eq(InboundOrderLookupEntity::getDeleted, 0));
}
public InboundOrderLookupEntity findInbound(long tenantId, long inboundId) {
return inboundOrders.selectOne(new LambdaQueryWrapper<InboundOrderLookupEntity>()
.eq(InboundOrderLookupEntity::getTenantId, tenantId)
.eq(InboundOrderLookupEntity::getId, inboundId)
.eq(InboundOrderLookupEntity::getDeleted, 0));
}
public InboundMeasurementEntity findEvent(long tenantId, String appKey, String eventNo) {
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
.eq(InboundMeasurementEntity::getTenantId, tenantId)
.eq(InboundMeasurementEntity::getSourceCode, appKey)
.eq(InboundMeasurementEntity::getExternalEventNo, eventNo));
}
public void insertMeasurement(InboundMeasurementEntity measurement) {
measurements.insert(measurement);
}
public void insertItem(InboundMeasurementItemEntity item) {
items.insert(item);
}
}
@@ -0,0 +1,133 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import com.baiyue.firstleg.mapper.InboundMeasurementItemMapper;
import com.baiyue.firstleg.mapper.InboundMeasurementMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.baiyue.firstleg.mapper.InboundWeightResultMapper;
import com.baiyue.firstleg.mapper.InboundWeightRevisionMapper;
import com.baiyue.firstleg.mapper.MeasureReviewRecordMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 入库测量仓储,所有读取都同时带租户和入库单边界。 */
@Repository
public class InboundMeasurementRepository {
private final InboundOrderMapper inboundOrders;
private final InboundMeasurementMapper measurements;
private final InboundMeasurementItemMapper items;
private final InboundWeightResultMapper weights;
private final InboundWeightRevisionMapper revisions;
private final MeasureReviewRecordMapper reviews;
public InboundMeasurementRepository(InboundOrderMapper inboundOrders,
InboundMeasurementMapper measurements,
InboundMeasurementItemMapper items,
InboundWeightResultMapper weights,
InboundWeightRevisionMapper revisions,
MeasureReviewRecordMapper reviews) {
this.inboundOrders = inboundOrders;
this.measurements = measurements;
this.items = items;
this.weights = weights;
this.revisions = revisions;
this.reviews = reviews;
}
public InboundOrderEntity findInbound(long tenantId, long inboundId) {
return inboundOrders.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(InboundOrderEntity::getId, inboundId));
}
public boolean updateInbound(InboundOrderEntity inbound) {
return inboundOrders.updateById(inbound) == 1;
}
public InboundMeasurementEntity findMeasurement(long tenantId, long inboundId, long measurementId) {
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
.eq(InboundMeasurementEntity::getTenantId, tenantId)
.eq(InboundMeasurementEntity::getInboundId, inboundId)
.eq(InboundMeasurementEntity::getId, measurementId));
}
public InboundMeasurementEntity findLatestMeasurement(long tenantId, long inboundId) {
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
.eq(InboundMeasurementEntity::getTenantId, tenantId)
.eq(InboundMeasurementEntity::getInboundId, inboundId)
.orderByDesc(InboundMeasurementEntity::getCreatedAt)
.last("LIMIT 1"));
}
public List<InboundMeasurementItemEntity> listItems(long tenantId, long measurementId) {
return items.selectList(new LambdaQueryWrapper<InboundMeasurementItemEntity>()
.eq(InboundMeasurementItemEntity::getTenantId, tenantId)
.eq(InboundMeasurementItemEntity::getMeasurementId, measurementId)
.orderByAsc(InboundMeasurementItemEntity::getItemNo));
}
public void insertMeasurement(InboundMeasurementEntity measurement) {
measurements.insert(measurement);
}
public boolean updateMeasurement(InboundMeasurementEntity measurement) {
return measurements.updateById(measurement) == 1;
}
public void replaceItems(long tenantId, long measurementId,
List<InboundMeasurementItemEntity> replacement) {
items.delete(new LambdaQueryWrapper<InboundMeasurementItemEntity>()
.eq(InboundMeasurementItemEntity::getTenantId, tenantId)
.eq(InboundMeasurementItemEntity::getMeasurementId, measurementId));
replacement.forEach(items::insert);
}
public List<InboundWeightResultEntity> listWeights(long tenantId, long inboundId) {
return weights.selectList(new LambdaQueryWrapper<InboundWeightResultEntity>()
.eq(InboundWeightResultEntity::getTenantId, tenantId)
.eq(InboundWeightResultEntity::getInboundId, inboundId)
.orderByAsc(InboundWeightResultEntity::getCreatedAt));
}
public InboundWeightResultEntity findWeight(long tenantId, long inboundId, long resultId) {
return weights.selectOne(new LambdaQueryWrapper<InboundWeightResultEntity>()
.eq(InboundWeightResultEntity::getTenantId, tenantId)
.eq(InboundWeightResultEntity::getInboundId, inboundId)
.eq(InboundWeightResultEntity::getId, resultId));
}
public InboundWeightResultEntity findWeightByFile(long tenantId, long inboundId, long fileId) {
return weights.selectOne(new LambdaQueryWrapper<InboundWeightResultEntity>()
.eq(InboundWeightResultEntity::getTenantId, tenantId)
.eq(InboundWeightResultEntity::getInboundId, inboundId)
.eq(InboundWeightResultEntity::getFileId, fileId));
}
public void insertWeight(InboundWeightResultEntity weight) {
weights.insert(weight);
}
public boolean updateWeight(InboundWeightResultEntity weight) {
return weights.updateById(weight) == 1;
}
public void insertRevision(InboundWeightRevisionEntity revision) {
revisions.insert(revision);
}
public MeasureReviewRecordEntity findLatestReview(long tenantId, long inboundId) {
return reviews.selectOne(new LambdaQueryWrapper<MeasureReviewRecordEntity>()
.eq(MeasureReviewRecordEntity::getTenantId, tenantId)
.eq(MeasureReviewRecordEntity::getInboundId, inboundId)
.orderByDesc(MeasureReviewRecordEntity::getReviewedAt)
.last("LIMIT 1"));
}
}
@@ -0,0 +1,194 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.dto.InboundOrderDetail;
import com.baiyue.firstleg.dto.InboundOrderQuery;
import com.baiyue.firstleg.dto.InboundOrderSummary;
import com.baiyue.firstleg.entity.InboundFileEntity;
import com.baiyue.firstleg.entity.InboundInternalNoteEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
import com.baiyue.firstleg.mapper.InboundFileMapper;
import com.baiyue.firstleg.mapper.InboundInternalNoteMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/** 入库单只读仓储,所有查询均以租户作为首要隔离条件。 */
@Repository
public class InboundOrderRepository {
private final InboundOrderMapper orders;
private final InboundTransportSnapshotMapper transports;
private final InboundPalletAllocationMapper pallets;
private final InboundFileMapper files;
private final InboundInternalNoteMapper notes;
private final InboundStatusHistoryMapper histories;
public InboundOrderRepository(
InboundOrderMapper orders,
InboundTransportSnapshotMapper transports,
InboundPalletAllocationMapper pallets,
InboundFileMapper files,
InboundInternalNoteMapper notes,
InboundStatusHistoryMapper histories) {
this.orders = orders;
this.transports = transports;
this.pallets = pallets;
this.files = files;
this.notes = notes;
this.histories = histories;
}
public PageResult<InboundOrderSummary> page(long tenantId, InboundOrderQuery query) {
LambdaQueryWrapper<InboundOrderEntity> wrapper = new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.likeRight(StringUtils.hasText(query.inboundCode()), InboundOrderEntity::getInboundCode,
query.inboundCode())
.likeRight(StringUtils.hasText(query.orderNo()), InboundOrderEntity::getOrderNo, query.orderNo())
.eq(StringUtils.hasText(query.processStatus()), InboundOrderEntity::getProcessStatus,
query.processStatus())
.eq(StringUtils.hasText(query.orderStatus()), InboundOrderEntity::getOrderStatusSnapshot,
query.orderStatus())
.eq(StringUtils.hasText(query.measureReviewStatus()), InboundOrderEntity::getMeasureReviewStatus,
query.measureReviewStatus())
.eq(StringUtils.hasText(query.feeStatus()), InboundOrderEntity::getFeeStatus, query.feeStatus())
.eq(StringUtils.hasText(query.feeReviewStatus()), InboundOrderEntity::getFeeReviewStatus,
query.feeReviewStatus())
.in(query.tab() != null, InboundOrderEntity::getProcessStatus,
query.tab() == null ? List.of() : query.tab().processStatuses())
.ge(query.expectedArrivalFrom() != null, InboundOrderEntity::getExpectedArrivalAt,
query.expectedArrivalFrom())
.le(query.expectedArrivalTo() != null, InboundOrderEntity::getExpectedArrivalAt,
query.expectedArrivalTo())
.eq(query.customerId() != null, InboundOrderEntity::getCustomerId, query.customerId())
.like(StringUtils.hasText(query.customerName()), InboundOrderEntity::getCustomerName,
query.customerName())
.eq(query.companyId() != null, InboundOrderEntity::getCompanyId, query.companyId())
.eq(query.warehouseId() != null, InboundOrderEntity::getWarehouseId, query.warehouseId())
.eq(query.deliveryWarehouseId() != null, InboundOrderEntity::getDeliveryWarehouseId,
query.deliveryWarehouseId())
.eq(StringUtils.hasText(query.destinationCode()), InboundOrderEntity::getDestinationCode,
query.destinationCode())
.eq(query.holdFlag() != null, InboundOrderEntity::getHoldFlag, query.holdFlag())
.orderByDesc(InboundOrderEntity::getCreatedAt)
.orderByDesc(InboundOrderEntity::getId);
Page<InboundOrderEntity> result = orders.selectPage(new Page<>(query.page(), query.size()), wrapper);
List<Long> ids = result.getRecords().stream().map(InboundOrderEntity::getId).toList();
Map<Long, InboundTransportSnapshotEntity> transportByInbound = transportMap(tenantId, ids);
Map<Long, String> locationCodesByInbound = locationCodesMap(tenantId, ids);
List<InboundOrderSummary> records = result.getRecords().stream()
.map(order -> InboundOrderSummary.from(order, transportByInbound.get(order.getId()),
locationCodesByInbound.get(order.getId())))
.toList();
return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
}
public InboundOrderEntity findOwned(long tenantId, long inboundId) {
return orders.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(InboundOrderEntity::getId, inboundId));
}
public InboundOrderEntity findByOrderId(long tenantId, long orderId) {
return orders.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(InboundOrderEntity::getOrderId, orderId)
.orderByDesc(InboundOrderEntity::getCreatedAt)
.last("LIMIT 1"));
}
public InboundOrderDetail detail(long tenantId, InboundOrderEntity order) {
long inboundId = order.getId();
InboundTransportSnapshotEntity transport = transports.selectOne(
owned(InboundTransportSnapshotEntity::getTenantId,
InboundTransportSnapshotEntity::getInboundId, tenantId, inboundId));
List<InboundPalletAllocationEntity> palletAllocations = pallets.selectList(
owned(InboundPalletAllocationEntity::getTenantId,
InboundPalletAllocationEntity::getInboundId, tenantId, inboundId)
.orderByAsc(InboundPalletAllocationEntity::getAllocationNo));
List<InboundOrderDetail.PalletLocationSummary> palletSummaries = palletAllocations.stream()
.map(item -> new InboundOrderDetail.PalletLocationSummary(
item.getAllocationNo(), item.getWarehouseId(), item.getPalletId(), item.getPalletCode(),
item.getPalletCapacityCbm(), item.getAllocatedVolumeCbm(), item.getAllocatedPackageCount(),
item.getLocationId(), item.getLocationCode(), item.getReservationNo(), item.getAllocationStatus()))
.toList();
List<InboundOrderDetail.FileSummary> fileSummaries = files.selectList(
owned(InboundFileEntity::getTenantId, InboundFileEntity::getInboundId, tenantId, inboundId)
.orderByAsc(InboundFileEntity::getFileType)
.orderByDesc(InboundFileEntity::getUploadedAt))
.stream().map(item -> new InboundOrderDetail.FileSummary(
item.getFileId(), item.getFileType(), item.getFileNameSnapshot(), item.getVisibility(),
item.getFileVersion(), item.getUploadedBy(), item.getUploadedAt()))
.toList();
List<InboundOrderDetail.InternalNoteSummary> noteSummaries = notes.selectList(
owned(InboundInternalNoteEntity::getTenantId,
InboundInternalNoteEntity::getInboundId, tenantId, inboundId)
.orderByDesc(InboundInternalNoteEntity::getCreatedAt))
.stream().map(item -> new InboundOrderDetail.InternalNoteSummary(
item.getId(), item.getContent(), item.getCreatedBy(), item.getCreatedByName(), item.getCreatedAt()))
.toList();
List<InboundOrderDetail.StatusHistorySummary> historySummaries = histories.selectList(
owned(InboundStatusHistoryEntity::getTenantId,
InboundStatusHistoryEntity::getInboundId, tenantId, inboundId)
.orderByDesc(InboundStatusHistoryEntity::getOccurredAt))
.stream().map(item -> new InboundOrderDetail.StatusHistorySummary(
item.getFromStatus(), item.getToStatus(), item.getAction(), item.getReason(),
item.getOperatorId(), item.getOperatorName(), item.getOccurredAt()))
.toList();
String locationCodes = palletAllocations.stream()
.map(InboundPalletAllocationEntity::getLocationCode)
.filter(StringUtils::hasText)
.collect(Collectors.joining("、"));
return new InboundOrderDetail(InboundOrderSummary.from(order, transport, locationCodes),
order.getPublicRemark(),
palletSummaries, fileSummaries, noteSummaries, historySummaries);
}
private Map<Long, InboundTransportSnapshotEntity> transportMap(long tenantId, List<Long> inboundIds) {
if (inboundIds.isEmpty()) {
return Map.of();
}
return transports.selectList(new LambdaQueryWrapper<InboundTransportSnapshotEntity>()
.eq(InboundTransportSnapshotEntity::getTenantId, tenantId)
.in(InboundTransportSnapshotEntity::getInboundId, inboundIds))
.stream().collect(Collectors.toMap(InboundTransportSnapshotEntity::getInboundId,
Function.identity(), (left, right) -> left));
}
private Map<Long, String> locationCodesMap(long tenantId, List<Long> inboundIds) {
if (inboundIds.isEmpty()) {
return Map.of();
}
return pallets.selectList(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.in(InboundPalletAllocationEntity::getInboundId, inboundIds)
.orderByAsc(InboundPalletAllocationEntity::getInboundId)
.orderByAsc(InboundPalletAllocationEntity::getAllocationNo))
.stream()
.filter(item -> StringUtils.hasText(item.getLocationCode()))
.collect(Collectors.groupingBy(InboundPalletAllocationEntity::getInboundId,
java.util.LinkedHashMap::new,
Collectors.mapping(InboundPalletAllocationEntity::getLocationCode,
Collectors.joining("、"))));
}
private static <T> LambdaQueryWrapper<T> owned(
com.baomidou.mybatisplus.core.toolkit.support.SFunction<T, ?> tenantColumn,
com.baomidou.mybatisplus.core.toolkit.support.SFunction<T, ?> inboundColumn,
long tenantId,
long inboundId) {
return new LambdaQueryWrapper<T>().eq(tenantColumn, tenantId).eq(inboundColumn, inboundId);
}
}
@@ -0,0 +1,51 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.client.WarehousePalletLocationClient.BatchResult;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Repository
public class InboundPalletAllocationRepository {
private final InboundPalletAllocationMapper mapper;
public InboundPalletAllocationRepository(InboundPalletAllocationMapper mapper) {
this.mapper = mapper;
}
@Transactional
public void replace(long tenantId, long inboundId, long warehouseId, BigDecimal capacity,
BatchResult result) {
mapper.delete(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId));
LocalDateTime now = LocalDateTime.now();
result.allocations().forEach(source -> {
InboundPalletAllocationEntity value = new InboundPalletAllocationEntity();
value.setTenantId(tenantId);
value.setInboundId(inboundId);
value.setAllocationNo(source.allocationNo());
value.setWarehouseId(warehouseId);
value.setPalletId(source.palletId());
value.setPalletCode(source.palletCode());
value.setPalletCapacityCbm(capacity);
value.setAllocatedVolumeCbm(source.allocatedVolumeCbm());
value.setLocationId(source.locationId());
value.setLocationCode(source.locationCode());
value.setReservationNo(source.reservationNo());
value.setAllocationStatus(normalize(source.state()));
value.setCreatedAt(now);
value.setUpdatedAt(now);
mapper.insert(value);
});
}
private static String normalize(String state) {
return "OCCUPIED".equals(state) ? "CONFIRMED" : state;
}
}
@@ -0,0 +1,154 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
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.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
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.InboundOrderMapper;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.MeasureReviewRecordMapper;
import org.springframework.stereotype.Repository;
/** 入库审核和完成编排仓储;所有业务查询强制携带租户。 */
@Repository
public class InboundWorkflowRepository {
private final InboundOrderMapper orders;
private final InboundMeasurementMapper measurements;
private final MeasureReviewRecordMapper reviews;
private final InboundStatusHistoryMapper histories;
private final InboundIdempotencyMapper idempotencies;
private final InboundFeeMapper fees;
private final InboundOutboxMapper outboxes;
private final InboundAllocationQueryMapper allocations;
public InboundWorkflowRepository(InboundOrderMapper orders,
InboundMeasurementMapper measurements,
MeasureReviewRecordMapper reviews,
InboundStatusHistoryMapper histories,
InboundIdempotencyMapper idempotencies,
InboundFeeMapper fees,
InboundOutboxMapper outboxes,
InboundAllocationQueryMapper allocations) {
this.orders = orders;
this.measurements = measurements;
this.reviews = reviews;
this.histories = histories;
this.idempotencies = idempotencies;
this.fees = fees;
this.outboxes = outboxes;
this.allocations = allocations;
}
public InboundOrderEntity findOwned(long tenantId, long inboundId) {
return orders.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(InboundOrderEntity::getId, inboundId));
}
public InboundMeasurementEntity findMeasurement(long tenantId, long inboundId, long measurementId) {
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
.eq(InboundMeasurementEntity::getTenantId, tenantId)
.eq(InboundMeasurementEntity::getInboundId, inboundId)
.eq(InboundMeasurementEntity::getId, measurementId));
}
public int transitionReview(long tenantId, long inboundId, long operatorId, int expectedVersion,
boolean approved) {
return approved
? orders.approveMeasurement(tenantId, inboundId, operatorId, expectedVersion)
: orders.rejectMeasurement(tenantId, inboundId, operatorId, expectedVersion);
}
public int transitionMeasurement(long tenantId, long inboundId, long measurementId, String result) {
return measurements.reviewSubmitted(tenantId, inboundId, measurementId, result);
}
public int releaseRejected(long tenantId, long inboundId, long operatorId, int expectedVersion) {
return orders.releaseRejectedMeasurement(tenantId, inboundId, operatorId, expectedVersion);
}
public void insertReview(MeasureReviewRecordEntity record) {
reviews.insert(record);
}
public PageResult<MeasureReviewRecordEntity> reviewHistory(
long tenantId, long inboundId, long page, long size) {
return PageResult.from(reviews.selectPage(new Page<>(page, size),
new LambdaQueryWrapper<MeasureReviewRecordEntity>()
.eq(MeasureReviewRecordEntity::getTenantId, tenantId)
.eq(MeasureReviewRecordEntity::getInboundId, inboundId)
.orderByDesc(MeasureReviewRecordEntity::getReviewedAt)
.orderByDesc(MeasureReviewRecordEntity::getId)));
}
public boolean hasExceptionRelease(long tenantId, long inboundId) {
return reviews.selectCount(new LambdaQueryWrapper<MeasureReviewRecordEntity>()
.eq(MeasureReviewRecordEntity::getTenantId, tenantId)
.eq(MeasureReviewRecordEntity::getInboundId, inboundId)
.eq(MeasureReviewRecordEntity::getExceptionRelease, true)) > 0;
}
public void insertHistory(InboundStatusHistoryEntity history) {
histories.insert(history);
}
public void insertIdempotency(InboundIdempotencyEntity record) {
idempotencies.insert(record);
}
public InboundIdempotencyEntity findIdempotency(
long tenantId, String operation, long operatorId, String key) {
return idempotencies.selectOne(new LambdaQueryWrapper<InboundIdempotencyEntity>()
.eq(InboundIdempotencyEntity::getTenantId, tenantId)
.eq(InboundIdempotencyEntity::getOperation, operation)
.eq(InboundIdempotencyEntity::getOperatorId, operatorId)
.eq(InboundIdempotencyEntity::getIdempotencyKey, key));
}
public int completeIdempotency(long id, String resultJson) {
return idempotencies.complete(id, resultJson);
}
public InboundFeeEntity findConfirmedFee(long tenantId, long inboundId, long feeId) {
return fees.selectOne(new LambdaQueryWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId)
.eq(InboundFeeEntity::getInboundId, inboundId)
.eq(InboundFeeEntity::getId, feeId)
.eq(InboundFeeEntity::getFeeStatus, "CONFIRMED"));
}
public long countPreparedAllocations(long tenantId, long inboundId) {
return allocations.countPrepared(tenantId, inboundId);
}
public int beginInbound(long tenantId, long inboundId, long operatorId, int expectedVersion) {
return orders.beginInbound(tenantId, inboundId, operatorId, expectedVersion);
}
public int completeInbound(long tenantId, long inboundId, long operatorId, int expectedVersion) {
return orders.completeInbound(tenantId, inboundId, operatorId, expectedVersion);
}
public int rollbackInbound(long tenantId, long inboundId, long operatorId, int expectedVersion) {
return orders.rollbackInbound(tenantId, inboundId, operatorId, expectedVersion);
}
public int markFeeSent(long tenantId, long inboundId, long feeId, long operatorId) {
return fees.markSent(tenantId, inboundId, feeId, operatorId);
}
public void insertOutbox(InboundOutboxEntity event) {
outboxes.insert(event);
}
}
@@ -0,0 +1,11 @@
package com.baiyue.firstleg.security;
import java.util.Map;
/** 签名校验通过后的第三方应用主体。 */
public record AuthenticatedMeasurementApp(
String appKey,
long tenantId,
long subjectId,
Map<String, Long> warehouseBindings) {
}
@@ -0,0 +1,142 @@
package com.baiyue.firstleg.security;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.config.MeasurementOpenApiProperties;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Map;
/** HMAC、时间戳和 Nonce 三项请求认证。 */
@Component
public class MeasurementRequestAuthenticator {
private static final String HMAC_ALGORITHM = "HmacSHA256";
private final MeasurementOpenApiProperties properties;
private final NonceReplayGuard nonceReplayGuard;
private final Clock clock;
public MeasurementRequestAuthenticator(MeasurementOpenApiProperties properties,
NonceReplayGuard nonceReplayGuard) {
this(properties, nonceReplayGuard, Clock.systemUTC());
}
MeasurementRequestAuthenticator(MeasurementOpenApiProperties properties,
NonceReplayGuard nonceReplayGuard,
Clock clock) {
this.properties = properties;
this.nonceReplayGuard = nonceReplayGuard;
this.clock = clock;
}
public AuthenticatedMeasurementApp authenticate(String method, String path,
MeasurementRequestHeaders headers,
String rawBody, boolean requireIdempotencyKey) {
if (!properties.isEnabled()) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "第三方测量开放接口未启用");
}
requireHeader(headers.appKey(), "X-App-Key");
requireHeader(headers.timestamp(), "X-Timestamp");
requireHeader(headers.nonce(), "X-Nonce");
requireHeader(headers.signature(), "X-Signature");
if (requireIdempotencyKey) {
requireHeader(headers.idempotencyKey(), "Idempotency-Key");
}
validateTokenLength(headers.nonce(), 128, "X-Nonce");
if (requireIdempotencyKey) {
validateTokenLength(headers.idempotencyKey(), 128, "Idempotency-Key");
}
MeasurementOpenApiProperties.AppCredential credential = properties.getApps().get(headers.appKey());
if (credential == null || !credential.isEnabled() || !StringUtils.hasText(credential.getSecret())
|| credential.getTenantId() == null || credential.getTenantId() <= 0
|| credential.getSubjectId() == null || credential.getSubjectId() <= 0) {
throw unauthorized("应用凭据无效");
}
long timestamp = parseTimestamp(headers.timestamp());
Duration drift = Duration.between(Instant.ofEpochMilli(timestamp), clock.instant()).abs();
if (drift.compareTo(properties.getMaxClockSkew()) > 0) {
throw unauthorized("请求时间戳已过期");
}
String canonical = canonicalString(method, path, headers.timestamp(), headers.nonce(),
requireIdempotencyKey ? headers.idempotencyKey() : "", sha256Hex(rawBody));
String expected = hmacHex(credential.getSecret(), canonical);
if (!constantTimeEquals(expected, headers.signature())) {
throw unauthorized("请求签名无效");
}
try {
if (!nonceReplayGuard.claim(headers.appKey(), headers.nonce(), properties.getNonceTtl())) {
throw new BizException(ResultCode.CONFLICT, "Nonce 已使用,拒绝重放请求");
}
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Nonce 防重放服务暂不可用");
}
return new AuthenticatedMeasurementApp(headers.appKey(), credential.getTenantId(),
credential.getSubjectId(), Map.copyOf(credential.getWarehouseBindings()));
}
public static String canonicalString(String method, String path, String timestamp, String nonce,
String idempotencyKey, String bodySha256) {
return String.join("\n", method.toUpperCase(java.util.Locale.ROOT), path, timestamp, nonce,
idempotencyKey == null ? "" : idempotencyKey, bodySha256);
}
public static String sha256Hex(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest((value == null ? "" : value).getBytes(StandardCharsets.UTF_8)));
} catch (Exception exception) {
throw new IllegalStateException("SHA-256 不可用", exception);
}
}
static String hmacHex(String secret, String canonical) {
try {
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM));
return HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));
} catch (Exception exception) {
throw new IllegalStateException("HMAC-SHA256 不可用", exception);
}
}
private long parseTimestamp(String timestamp) {
try {
return Long.parseLong(timestamp);
} catch (NumberFormatException exception) {
throw unauthorized("X-Timestamp 必须为 Unix 毫秒时间戳");
}
}
private boolean constantTimeEquals(String expected, String actual) {
return MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII),
actual.toLowerCase(java.util.Locale.ROOT).getBytes(StandardCharsets.US_ASCII));
}
private void requireHeader(String value, String name) {
if (!StringUtils.hasText(value)) {
throw unauthorized("缺少请求头 " + name);
}
}
private void validateTokenLength(String value, int max, String name) {
if (value.length() > max) {
throw unauthorized(name + " 长度超过限制");
}
}
private BizException unauthorized(String message) {
return new BizException(ResultCode.UNAUTHORIZED, message);
}
}
@@ -0,0 +1,10 @@
package com.baiyue.firstleg.security;
/** 第三方测量请求的认证和幂等请求头。 */
public record MeasurementRequestHeaders(
String appKey,
String timestamp,
String nonce,
String signature,
String idempotencyKey) {
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.security;
import java.time.Duration;
/** 原子占用 Nonce,返回 false 表示同一应用已使用过该 Nonce。 */
public interface NonceReplayGuard {
boolean claim(String appKey, String nonce, Duration ttl);
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.security;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
/** 使用 Redis SET NX EX 实现跨实例 Nonce 防重放。 */
@Component
public class RedisNonceReplayGuard implements NonceReplayGuard {
private static final String KEY_PREFIX = "baiyue:firstleg:measurement-openapi:nonce:";
private final StringRedisTemplate redisTemplate;
public RedisNonceReplayGuard(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public boolean claim(String appKey, String nonce, Duration ttl) {
Boolean claimed = redisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + appKey + ":" + nonce, "1", ttl);
return Boolean.TRUE.equals(claimed);
}
}
@@ -0,0 +1,69 @@
package com.baiyue.firstleg.service;
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.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import org.springframework.stereotype.Service;
@Service
public class AutomaticInboundCompletionService {
private final FbaPricingService pricing;
private final InboundWorkflowRepository workflow;
private final InboundCompletionOrchestrator completion;
public AutomaticInboundCompletionService(FbaPricingService pricing,
InboundWorkflowRepository workflow,
InboundCompletionOrchestrator completion) {
this.pricing = pricing;
this.workflow = workflow;
this.completion = completion;
}
public FeeSnapshotResponse completeApproved(long tenantId, long inboundId, long operatorId,
String operatorName, long measurementId,
int reviewedOrderVersion) {
String key = "auto-" + inboundId + "-" + measurementId + "-" + reviewedOrderVersion;
InboundOrderEntity order = requireOrder(tenantId, inboundId);
if (completed(order)) return pricing.latest(tenantId, inboundId);
if ("WAIT_PRICING".equals(order.getProcessStatus()) && "UNPRICED".equals(order.getFeeStatus())) {
pricing.calculate(tenantId, operatorId, inboundId, null, key + "-calculate");
order = requireOrder(tenantId, inboundId);
}
if ("FEE_CONFIRMING".equals(order.getProcessStatus())
&& "CALCULATED".equals(order.getFeeStatus())) {
pricing.confirm(tenantId, operatorId, inboundId, key + "-confirm");
order = requireOrder(tenantId, inboundId);
}
FeeSnapshotResponse fee = pricing.latest(tenantId, inboundId);
if ("FEE_CONFIRMING".equals(order.getProcessStatus())
&& "CONFIRMED".equals(order.getFeeStatus())) {
completion.beginSendFeeAndInbound(tenantId, operatorId, operatorName, inboundId,
fee.feeId(), order.getVersion(), key + "-begin");
order = requireOrder(tenantId, inboundId);
}
if ("INBOUNDING".equals(order.getProcessStatus())
&& "CONFIRMED".equals(order.getFeeStatus())) {
completion.completeSendFeeAndInbound(tenantId, operatorId, operatorName, inboundId,
fee.feeId(), order.getVersion(), key + "-complete");
order = requireOrder(tenantId, inboundId);
}
if (!completed(order)) {
throw new BizException(ResultCode.CONFLICT, "客服审核后的自动计费入库流程状态异常");
}
return pricing.latest(tenantId, inboundId);
}
private static boolean completed(InboundOrderEntity order) {
return "INBOUNDED".equals(order.getProcessStatus()) && "SENT".equals(order.getFeeStatus());
}
private InboundOrderEntity requireOrder(long tenantId, long inboundId) {
InboundOrderEntity order = workflow.findOwned(tenantId, inboundId);
if (order == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
return order;
}
}
@@ -0,0 +1,145 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FbaPricingDtos.CarrierRateVersionRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
/** 客户规则和船司价格版本管理。版本内容创建后不可原地修改。 */
@Service
public class FbaMasterDataService {
private final FbaPricingRepository repository;
public FbaMasterDataService(FbaPricingRepository repository) { this.repository = repository; }
@Transactional
public CustomerChargeRuleEntity createRuleVersion(long tenantId, long operatorId,
CustomerRuleVersionRequest request) {
validatePeriod(request.effectiveFrom(), request.effectiveTo());
boolean overlaps = repository.listRules(tenantId, null).stream()
.filter(v -> "ACTIVE".equals(v.getStatus()))
.filter(v -> Objects.equals(v.getCustomerId(), request.customerId()))
.filter(v -> Objects.equals(v.getBusinessType(), request.businessType()))
.filter(v -> Objects.equals(v.getTaxMode(), request.taxMode()))
.anyMatch(v -> overlaps(v.getEffectiveFrom(), v.getEffectiveTo(),
request.effectiveFrom(), request.effectiveTo()));
if (overlaps) throw conflict("客户计费规则有效期重叠,无法保证唯一匹配");
CustomerChargeRuleEntity value = new CustomerChargeRuleEntity();
value.setTenantId(tenantId);
value.setRuleCode(request.ruleCode());
value.setRuleVersion(repository.nextRuleVersion(tenantId, request.ruleCode()));
value.setCustomerId(request.customerId());
value.setCustomerName(request.customerName());
value.setBusinessType(request.businessType());
value.setChargeMode(request.chargeMode());
value.setTaxMode(request.taxMode());
value.setConversionFactor(request.conversionFactor());
value.setRoundingScale(request.roundingScale());
value.setRoundingMode(request.roundingMode());
value.setCurrency(request.currency());
value.setEffectiveFrom(request.effectiveFrom());
value.setEffectiveTo(request.effectiveTo());
value.setStatus("ACTIVE");
value.setRemark(request.remark());
value.setCreatedBy(operatorId);
value.setUpdatedBy(operatorId);
repository.insertRule(value);
return value;
}
@Transactional
public CarrierFbaRateEntity createRateVersion(long tenantId, long operatorId,
CarrierRateVersionRequest request) {
validatePeriod(request.effectiveFrom(), request.effectiveTo());
boolean overlaps = repository.listRates(tenantId, null).stream()
.filter(v -> "ACTIVE".equals(v.getStatus()))
.filter(v -> Objects.equals(v.getOriginWarehouseId(), request.originWarehouseId()))
.filter(v -> Objects.equals(v.getBusinessType(), request.businessType()))
.filter(v -> Objects.equals(v.getChannelCode(), request.channelCode()))
.filter(v -> Objects.equals(v.getCarrierId(), request.carrierId()))
.filter(v -> Objects.equals(v.getDestinationCode(), request.destinationCode()))
.filter(v -> Objects.equals(v.getChargeUnit(), request.chargeUnit()))
.anyMatch(v -> overlaps(v.getEffectiveFrom(), v.getEffectiveTo(),
request.effectiveFrom(), request.effectiveTo()));
if (overlaps) throw conflict("船司 FBA 价格有效期重叠,无法保证唯一匹配");
CarrierFbaRateEntity value = new CarrierFbaRateEntity();
value.setTenantId(tenantId);
value.setRateCode(request.rateCode());
value.setRateVersion(repository.nextRateVersion(tenantId, request.rateCode()));
value.setOriginWarehouseId(request.originWarehouseId());
value.setOriginWarehouseName(request.originWarehouseName());
value.setBusinessType(request.businessType());
value.setChannelCode(request.channelCode());
value.setCarrierId(request.carrierId());
value.setCarrierName(request.carrierName());
value.setDestinationCode(request.destinationCode());
value.setChargeUnit(request.chargeUnit());
value.setUnitPrice(request.unitPrice());
value.setCurrency(request.currency());
value.setAdditionalFee(request.additionalFee());
value.setEffectiveFrom(request.effectiveFrom());
value.setEffectiveTo(request.effectiveTo());
value.setStatus("ACTIVE");
value.setRemark(request.remark());
value.setCreatedBy(operatorId);
value.setUpdatedBy(operatorId);
repository.insertRate(value);
return value;
}
public List<CustomerChargeRuleEntity> listRules(long tenantId, String code) {
return repository.listRules(tenantId, code);
}
public List<CarrierFbaRateEntity> listRates(long tenantId, String code) {
return repository.listRates(tenantId, code);
}
public CustomerChargeRuleEntity rule(long tenantId, long id) {
CustomerChargeRuleEntity value = repository.findRule(tenantId, id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "客户计费规则版本不存在");
return value;
}
public CarrierFbaRateEntity rate(long tenantId, long id) {
CarrierFbaRateEntity value = repository.findRate(tenantId, id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "船司 FBA 价格版本不存在");
return value;
}
@Transactional
public void retireRule(long tenantId, long id, long operatorId) {
if (repository.retireRule(tenantId, id, operatorId) != 1) throw conflict("规则已停用或不存在");
}
@Transactional
public void retireRate(long tenantId, long id, long operatorId) {
if (repository.retireRate(tenantId, id, operatorId) != 1) throw conflict("价格已停用或不存在");
}
private static void validatePeriod(LocalDateTime from, LocalDateTime to) {
if (to != null && !to.isAfter(from)) {
throw new BizException(ResultCode.BAD_REQUEST, "有效期结束时间必须晚于开始时间");
}
}
private static boolean overlaps(LocalDateTime aFrom, LocalDateTime aTo,
LocalDateTime bFrom, LocalDateTime bTo) {
return (aTo == null || bFrom.isBefore(aTo)) && (bTo == null || aFrom.isBefore(bTo));
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}
@@ -0,0 +1,381 @@
package com.baiyue.firstleg.service;
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.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.repository.FbaPricingRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.beans.BeanUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.List;
/** FBA 费用试算、确认和发送状态机。 */
@Service
public class FbaPricingService {
private static final int MONEY_SCALE = 2;
private final FbaPricingRepository repository;
private final Clock clock;
public FbaPricingService(FbaPricingRepository repository) {
this(repository, Clock.systemDefaultZone());
}
FbaPricingService(FbaPricingRepository repository, Clock clock) {
this.repository = repository;
this.clock = clock;
}
@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(), "FEE_CONFIRMING");
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("客户规则换算系数或精度无效");
}
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 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,
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, rule.getChargeMode(), chargeable) != 1) {
throw conflict("入库单状态已变化,费用试算未保存");
}
complete(token, fee.getId(), now);
return response(inbound, fee, rate.getUnitPrice(), "FEE_CONFIRMING");
}
@Transactional
public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId,
String idempotencyKey) {
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
if (current == null) throw conflict("入库单尚无可确认的费用试算");
String hash = hash("CONFIRM", inboundId, current.getId().toString());
InboundPricingIdempotencyEntity token = beginIdempotency(
tenantId, operatorId, inboundId, "FEE_CONFIRM", idempotencyKey, hash);
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), "FEE_CONFIRMING");
LocalDateTime now = LocalDateTime.now(clock);
if (repository.confirmFee(tenantId, current.getId(), operatorId, now) != 1
|| repository.markInboundConfirmed(tenantId, inboundId) != 1) {
throw conflict("费用或入库单状态已变化,不能重复确认");
}
complete(token, current.getId(), now);
return response(tenantId, current.getId(), "FEE_CONFIRMING");
}
@Transactional
public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId,
String idempotencyKey) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
if (current == null) throw conflict("入库单尚无费用快照");
String hash = hash("SEND", inboundId, current.getId().toString());
InboundPricingIdempotencyEntity token = beginIdempotency(
tenantId, operatorId, inboundId, "FEE_SEND", idempotencyKey, hash);
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), "WAIT_PALLET");
LocalDateTime now = LocalDateTime.now(clock);
if (repository.sendFee(tenantId, current.getId(), operatorId, now) != 1
|| repository.markInboundWaitingPallet(tenantId, inboundId) != 1) {
throw conflict("费用未确认或入库单状态已变化,不能发送");
}
InboundOutboxEntity event = new InboundOutboxEntity();
event.setTenantId(tenantId);
event.setAggregateType("INBOUND");
event.setAggregateId(inboundId);
event.setEventType("FIRSTLEG_FEE_SENT");
event.setPayloadJson("{\"inboundId\":\"" + inboundId + "\",\"feeId\":\"" + current.getId() + "\"}");
event.setPublishStatus("PENDING");
event.setRetryCount(0);
event.setCreatedAt(now);
repository.insertOutbox(event);
complete(token, current.getId(), now);
return response(tenantId, current.getId(), "WAIT_PALLET");
}
public FeeSnapshotResponse latest(long tenantId, long inboundId) {
InboundFeeEntity fee = repository.findLatestFee(tenantId, inboundId);
if (fee == null) throw new BizException(ResultCode.NOT_FOUND, "费用快照不存在");
return response(tenantId, fee.getId(), null);
}
@Transactional
public FeeSnapshotResponse adjustPublished(long tenantId, long operatorId, long inboundId,
AdjustFeeRequest request, String idempotencyKey) {
InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
if (!"INBOUNDED".equals(inbound.getProcessStatus()) || !"SENT".equals(inbound.getFeeStatus())) {
throw conflict("只能修改已完成入库且已发布的费用");
}
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
if (current == null || !"SENT".equals(current.getFeeStatus())) {
throw conflict("入库单没有可修改的已发布费用");
}
String discriminator = current.getId() + ":" + request.subtotalAmount() + ":"
+ request.additionalAmount() + ":" + request.reason().trim();
InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId,
"FEE_ADJUST", idempotencyKey, hash("ADJUST", inboundId, discriminator));
if ("COMPLETED".equals(token.getStatus())) {
return response(tenantId, token.getResultResourceId(), "INBOUNDED");
}
LocalDateTime now = LocalDateTime.now(clock);
int nextVersion = repository.nextFeeVersion(tenantId, inboundId);
InboundFeeEntity adjusted = new InboundFeeEntity();
BeanUtils.copyProperties(current, adjusted);
adjusted.setId(null);
adjusted.setFeeNo("FBAF-" + inboundId + "-" + nextVersion);
adjusted.setFeeVersion(nextVersion);
adjusted.setSubtotalAmount(request.subtotalAmount().setScale(MONEY_SCALE, RoundingMode.HALF_UP));
adjusted.setAdditionalAmount(request.additionalAmount().setScale(MONEY_SCALE, RoundingMode.HALF_UP));
adjusted.setTotalAmount(adjusted.getSubtotalAmount().add(adjusted.getAdditionalAmount()));
adjusted.setFeeStatus("SENT");
adjusted.setReviewStatus("NOT_REQUIRED");
adjusted.setCalculatedBy(operatorId);
adjusted.setCalculatedAt(now);
adjusted.setConfirmedBy(operatorId);
adjusted.setConfirmedAt(now);
adjusted.setSentBy(operatorId);
adjusted.setSentAt(now);
adjusted.setCreatedAt(now);
adjusted.setUpdatedAt(now);
if (repository.supersedeSent(tenantId, current.getId()) != 1) {
throw conflict("费用已被其他操作员修改,请刷新后重试");
}
repository.insertFee(adjusted);
BigDecimal quantity = current.getChargeableQuantity();
InboundFeeItemEntity base = item(tenantId, adjusted, 1, "FBA_BASE", "FBA基础运费",
adjusted.getSubtotalAmount(), now);
base.setQuantity(quantity);
base.setUnit(current.getChargeUnit());
base.setUnitPrice(quantity.signum() == 0 ? BigDecimal.ZERO
: adjusted.getSubtotalAmount().divide(quantity, 6, RoundingMode.HALF_UP));
base.setAutomaticFlag(false);
base.setRemark(request.reason().trim());
repository.insertItem(base);
if (adjusted.getAdditionalAmount().signum() > 0) {
InboundFeeItemEntity extra = additionalItem(tenantId, adjusted,
adjusted.getAdditionalAmount(), now);
extra.setAutomaticFlag(false);
extra.setRemark(request.reason().trim());
repository.insertItem(extra);
}
InboundOutboxEntity event = new InboundOutboxEntity();
event.setTenantId(tenantId);
event.setAggregateType("INBOUND");
event.setAggregateId(inboundId);
event.setEventType("FIRSTLEG_FEE_ADJUSTED");
event.setPayloadJson("{\"inboundId\":\"" + inboundId + "\",\"feeId\":\""
+ adjusted.getId() + "\",\"feeVersion\":" + nextVersion + "}");
event.setPublishStatus("PENDING");
event.setRetryCount(0);
event.setCreatedAt(now);
repository.insertOutbox(event);
complete(token, adjusted.getId(), now);
return response(tenantId, adjusted.getId(), "INBOUNDED");
}
private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!List.of("WAIT_PRICING", "FEE_CONFIRMING").contains(inbound.getProcessStatus())
|| !List.of("UNPRICED", "CALCULATED").contains(inbound.getFeeStatus())) {
throw conflict("当前入库单状态不允许费用试算");
}
return inbound;
}
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 (!"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 仓匹配维度");
}
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 InboundPricingIdempotencyEntity beginIdempotency(long tenantId, long operatorId,
long inboundId, String operation,
String key, String hash) {
if (!StringUtils.hasText(key) || key.length() > 128) {
throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key 必填且不能超过128字符");
}
InboundPricingIdempotencyEntity existing = repository.findIdempotency(tenantId, operatorId, operation, key);
if (existing != null) {
if (!hash.equals(existing.getRequestHash())) throw conflict("幂等键已用于不同请求");
if (!"COMPLETED".equals(existing.getStatus())) throw conflict("相同请求正在处理中");
return existing;
}
InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity();
value.setTenantId(tenantId);
value.setOperation(operation);
value.setOperatorId(operatorId);
value.setInboundId(inboundId);
value.setIdempotencyKey(key);
value.setRequestHash(hash);
value.setStatus("PROCESSING");
value.setCreatedAt(LocalDateTime.now(clock));
value.setUpdatedAt(LocalDateTime.now(clock));
repository.insertIdempotency(value);
return value;
}
private void complete(InboundPricingIdempotencyEntity token, long resourceId, LocalDateTime now) {
if (repository.completeIdempotency(token, resourceId, now) != 1) throw conflict("幂等记录状态已变化");
}
private InboundFeeEntity snapshot(long tenantId, long operatorId, InboundPricingEntity inbound,
CustomerChargeRuleEntity rule, CarrierFbaRateEntity 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.setCarrierRateId(rate.getId()); fee.setCarrierRateVersion(rate.getRateVersion());
fee.setRuleCode(rule.getRuleCode()); 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.setOriginWarehouseId(rate.getOriginWarehouseId()); fee.setOriginWarehouseName(rate.getOriginWarehouseName());
fee.setChannelCode(rate.getChannelCode()); fee.setCarrierId(rate.getCarrierId());
fee.setCarrierName(rate.getCarrierName()); fee.setDestinationCode(rate.getDestinationCode());
fee.setRateEffectiveFrom(rate.getEffectiveFrom()); fee.setRateEffectiveTo(rate.getEffectiveTo());
fee.setChargeMode(rule.getChargeMode()); fee.setConversionFactor(rule.getConversionFactor());
fee.setActualWeightKg(weight); fee.setActualVolumeCbm(volume); fee.setConvertedQuantity(converted);
fee.setChargeableQuantity(chargeable); fee.setChargeUnit(rule.getChargeMode()); 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,
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());
return item;
}
private InboundFeeItemEntity additionalItem(long tenantId, InboundFeeEntity fee,
BigDecimal amount, LocalDateTime now) {
InboundFeeItemEntity item = item(tenantId, fee, 2, "FBA_ADDITIONAL", "FBA固定附加费", amount, now);
item.setQuantity(BigDecimal.ONE.setScale(4)); item.setUnit("ORDER"); item.setUnitPrice(amount);
return item;
}
private InboundFeeItemEntity item(long tenantId, InboundFeeEntity fee, int no, String code,
String name, BigDecimal amount, LocalDateTime now) {
InboundFeeItemEntity item = new InboundFeeItemEntity();
item.setTenantId(tenantId); item.setFeeId(fee.getId()); item.setItemNo(no);
item.setFeeCode(code); item.setFeeName(name); item.setAmount(amount); item.setCurrency(fee.getCurrency());
item.setAutomaticFlag(true); item.setCreatedAt(now); return item;
}
private FeeSnapshotResponse response(long tenantId, long feeId, String nextStatus) {
InboundFeeEntity fee = repository.findFee(tenantId, feeId);
if (fee == null) throw new BizException(ResultCode.NOT_FOUND, "费用快照不存在");
InboundPricingEntity inbound = repository.findInbound(tenantId, fee.getInboundId());
InboundFeeItemEntity base = repository.findBaseItem(tenantId, feeId);
String actualStatus = nextStatus == null && inbound != null
? inbound.getProcessStatus() : nextStatus;
return response(inbound, fee, base == null ? null : base.getUnitPrice(), actualStatus);
}
private FeeSnapshotResponse response(InboundPricingEntity inbound, InboundFeeEntity fee,
BigDecimal unitPrice, String nextStatus) {
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.getCustomerId(), fee.getCustomerName(), fee.getOriginWarehouseId(), fee.getOriginWarehouseName(),
fee.getBusinessType(), fee.getChannelCode(), fee.getCarrierId(), fee.getCarrierName(),
fee.getDestinationCode(), fee.getTaxMode(), 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(),
fee.getRateEffectiveFrom(), fee.getRateEffectiveTo(), fee.getCalculatedAt(), fee.getConfirmedAt(),
fee.getSentAt(), nextStatus);
}
private static String hash(String operation, long inboundId, String discriminator) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(
(operation + ":" + inboundId + ":" + discriminator).getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("JVM不支持SHA-256", exception);
}
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
/** 头程平台接口租户边界校验。 */
public final class FirstlegScopeGuard {
private FirstlegScopeGuard() {}
public static long platform(AuthenticatedUser user, long targetTenantId) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
if (targetTenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户");
}
return targetTenantId;
}
}
@@ -0,0 +1,156 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
/**
* 发送费用并完成入库的状态编排骨架。
*
* <p>本服务不计算费用、不创建托盘/库位,也不调用仓库 CRUD。测量完成阶段已确认托盘和库位占用;
* 客服审核通过后的自动编排调用 {@link #beginSendFeeAndInbound} 进入 INBOUNDING,再调用
* {@link #completeSendFeeAndInbound} 发布费用并完成入库。</p>
*/
@Service
public class InboundCompletionOrchestrator {
private final InboundWorkflowRepository repository;
private final InboundIdempotencyService idempotency;
public InboundCompletionOrchestrator(InboundWorkflowRepository repository,
InboundIdempotencyService idempotency) {
this.repository = repository;
this.idempotency = idempotency;
}
/**
* 任务5对接方法一:锁定发送动作。允许前置状态仅为 FEE_CONFIRMING + CONFIRMED。
*/
@Transactional
public void beginSendFeeAndInbound(long tenantId, long operatorId, String operatorName,
long inboundId, long feeId, int expectedVersion,
String idempotencyKey) {
String canonical = inboundId + "|" + feeId + "|" + expectedVersion;
InboundIdempotencyService.Context context = idempotency.begin(
tenantId, operatorId, inboundId, "SEND_FEE_BEGIN", idempotencyKey, canonical);
if (context.replayed()) {
return;
}
InboundOrderEntity order = requireOrder(tenantId, inboundId);
if (!"FEE_CONFIRMING".equals(order.getProcessStatus()) || !"CONFIRMED".equals(order.getFeeStatus())) {
throw new BizException(ResultCode.CONFLICT, "费用未确认或入库单不在待发送阶段");
}
boolean released = measurementGatePassed(order, tenantId, inboundId);
InboundFeeEntity fee = repository.findConfirmedFee(tenantId, inboundId, feeId);
if (fee == null) {
throw new BizException(ResultCode.CONFLICT, "缺少已确认且不可变的费用快照");
}
if (Boolean.TRUE.equals(fee.getExceptionRelease()) != released) {
throw new BizException(ResultCode.CONFLICT, "费用快照的异常放行标记与审核历史不一致");
}
if (repository.countPreparedAllocations(tenantId, inboundId) <= 0) {
throw new BizException(ResultCode.CONFLICT, "尚未准备托盘和库位预占引用");
}
if (repository.beginInbound(tenantId, inboundId, operatorId, expectedVersion) != 1) {
throw concurrentConflict();
}
repository.insertHistory(MeasurementReviewService.history(tenantId, inboundId,
"FEE_CONFIRMING", "INBOUNDING", "SEND_FEE_BEGIN", null, operatorId, operatorName));
idempotency.complete(context, inboundId);
}
/**
* 任务5对接方法二:仓库确认占用成功后,原子锁定费用、完成入库并写 Outbox。
*/
@Transactional
public void completeSendFeeAndInbound(long tenantId, long operatorId, String operatorName,
long inboundId, long feeId, int expectedVersion,
String idempotencyKey) {
String canonical = inboundId + "|" + feeId + "|" + expectedVersion;
InboundIdempotencyService.Context context = idempotency.begin(
tenantId, operatorId, inboundId, "SEND_FEE_COMPLETE", idempotencyKey, canonical);
if (context.replayed()) {
return;
}
InboundOrderEntity order = requireOrder(tenantId, inboundId);
if (!"INBOUNDING".equals(order.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "入库单不在入库处理中");
}
if (repository.completeInbound(tenantId, inboundId, operatorId, expectedVersion) != 1) {
throw concurrentConflict();
}
if (repository.markFeeSent(tenantId, inboundId, feeId, operatorId) != 1) {
throw new BizException(ResultCode.CONFLICT, "费用快照已被并发发送或状态不正确");
}
repository.insertHistory(MeasurementReviewService.history(tenantId, inboundId,
"INBOUNDING", "INBOUNDED", "SEND_FEE_COMPLETE", null, operatorId, operatorName));
repository.insertOutbox(completedEvent(tenantId, inboundId, feeId));
idempotency.complete(context, inboundId);
}
/** 任务5对接方法三:仅在仓库预占已释放后回退,不能回退已完成实物入库。 */
@Transactional
public void rollbackSendFeeAndInbound(long tenantId, long operatorId, String operatorName,
long inboundId, int expectedVersion, String reason,
String idempotencyKey) {
if (!StringUtils.hasText(reason)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "入库回退必须记录失败原因");
}
String canonical = inboundId + "|" + expectedVersion + "|" + (reason == null ? "" : reason.trim());
InboundIdempotencyService.Context context = idempotency.begin(
tenantId, operatorId, inboundId, "SEND_FEE_ROLLBACK", idempotencyKey, canonical);
if (context.replayed()) {
return;
}
requireOrder(tenantId, inboundId);
if (repository.rollbackInbound(tenantId, inboundId, operatorId, expectedVersion) != 1) {
throw concurrentConflict();
}
repository.insertHistory(MeasurementReviewService.history(tenantId, inboundId,
"INBOUNDING", "FEE_CONFIRMING", "SEND_FEE_ROLLBACK", reason, operatorId, operatorName));
idempotency.complete(context, inboundId);
}
private boolean measurementGatePassed(InboundOrderEntity order, long tenantId, long inboundId) {
if ("APPROVED".equals(order.getMeasureReviewStatus())) {
return false;
}
if ("REJECTED".equals(order.getMeasureReviewStatus())
&& repository.hasExceptionRelease(tenantId, inboundId)) {
return true;
}
throw new BizException(ResultCode.CONFLICT, "测量未审核通过且无有效异常放行记录");
}
private InboundOrderEntity requireOrder(long tenantId, long inboundId) {
InboundOrderEntity order = repository.findOwned(tenantId, inboundId);
if (order == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
}
return order;
}
private static InboundOutboxEntity completedEvent(long tenantId, long inboundId, long feeId) {
InboundOutboxEntity event = new InboundOutboxEntity();
event.setTenantId(tenantId);
event.setAggregateType("INBOUND");
event.setAggregateId(inboundId);
event.setEventType("FIRSTLEG_INBOUND_FEE_SENT");
event.setPayloadJson("{\"inboundId\":\"" + inboundId + "\",\"feeId\":\"" + feeId + "\"}");
event.setPublishStatus("PENDING");
event.setRetryCount(0);
event.setCreatedAt(LocalDateTime.now());
return event;
}
private static BizException concurrentConflict() {
return new BizException(ResultCode.CONFLICT, "入库单已被并发更新,请刷新后重试");
}
}
@@ -0,0 +1,81 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundIdempotencyEntity;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
/** 入库写操作的统一持久化幂等边界。 */
@Service
public class InboundIdempotencyService {
private final InboundWorkflowRepository repository;
public InboundIdempotencyService(InboundWorkflowRepository repository) {
this.repository = repository;
}
public Context begin(long tenantId, long operatorId, long inboundId,
String operation, String idempotencyKey, String canonicalRequest) {
String key = idempotencyKey == null ? null : idempotencyKey.trim();
if (!StringUtils.hasText(key)) {
throw new BizException(ResultCode.BAD_REQUEST, "必须提供 Idempotency-Key");
}
if (key.length() > 128) {
throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key 长度不能超过128");
}
String hash = sha256(canonicalRequest);
InboundIdempotencyEntity record = new InboundIdempotencyEntity();
record.setTenantId(tenantId);
record.setOperation(operation);
record.setOperatorId(operatorId);
record.setInboundId(inboundId);
record.setIdempotencyKey(key);
record.setRequestHash(hash);
record.setStatus("PROCESSING");
record.setCreatedAt(LocalDateTime.now());
record.setUpdatedAt(record.getCreatedAt());
try {
repository.insertIdempotency(record);
return new Context(record.getId(), false);
} catch (DuplicateKeyException duplicate) {
InboundIdempotencyEntity existing = repository.findIdempotency(
tenantId, operation, operatorId, key);
if (existing == null || !hash.equals(existing.getRequestHash())) {
throw new BizException(ResultCode.CONFLICT,
"Idempotency-Key 已用于不同请求");
}
if ("SUCCESS".equals(existing.getStatus())) {
return new Context(existing.getId(), true);
}
throw new BizException(ResultCode.CONFLICT, "相同请求正在处理中,请稍后重试");
}
}
public void complete(Context context, long inboundId) {
if (!context.replayed()
&& repository.completeIdempotency(context.id(), "{\"inboundId\":\"" + inboundId + "\"}") != 1) {
throw new BizException(ResultCode.CONFLICT, "幂等记录已被并发修改");
}
}
private static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("JVM 不支持 SHA-256", impossible);
}
}
public record Context(long id, boolean replayed) {
}
}

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