二期内容提交
This commit is contained in:
+1
@@ -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())
|
||||
|
||||
@@ -7,3 +7,4 @@ spring:
|
||||
|
||||
# 当前代码只实现本地/共享文件目录,非本地环境必须显式注入并挂载可写目录。
|
||||
baiyue.file.storage-root: ${FILE_STORAGE_ROOT}
|
||||
baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+59
@@ -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
@@ -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);
|
||||
}
|
||||
+29
@@ -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<>();
|
||||
}
|
||||
}
|
||||
+39
@@ -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));
|
||||
}
|
||||
}
|
||||
+75
@@ -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)));
|
||||
}
|
||||
}
|
||||
+108
@@ -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);
|
||||
}
|
||||
}
|
||||
+90
@@ -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));
|
||||
}
|
||||
}
|
||||
+134
@@ -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, "必须指定有效目标租户和操作人");
|
||||
}
|
||||
}
|
||||
}
|
||||
+122
@@ -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, "必须指定有效目标租户");
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
@@ -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;
|
||||
}
|
||||
+30
@@ -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); }
|
||||
}
|
||||
+56
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+133
@@ -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);
|
||||
}
|
||||
}
|
||||
+51
@@ -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) {
|
||||
}
|
||||
+142
@@ -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);
|
||||
}
|
||||
}
|
||||
+69
@@ -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;
|
||||
}
|
||||
}
|
||||
+156
@@ -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
Reference in New Issue
Block a user