diff --git a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java index f7b81b803..65199266b 100644 --- a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java +++ b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java @@ -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()) diff --git a/config/nacos/cn/file-service.yml b/config/nacos/cn/file-service.yml index 429ab4a59..7390c91fa 100644 --- a/config/nacos/cn/file-service.yml +++ b/config/nacos/cn/file-service.yml @@ -7,3 +7,4 @@ spring: # 当前代码只实现本地/共享文件目录,非本地环境必须显式注入并挂载可写目录。 baiyue.file.storage-root: ${FILE_STORAGE_ROOT} +baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:} diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/cn/firstleg-service.yml index 4baf82f98..f22fc6e4a 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/cn/firstleg-service.yml @@ -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} diff --git a/config/nacos/local/file-service.yml b/config/nacos/local/file-service.yml index bfc359e34..b61b517cc 100644 --- a/config/nacos/local/file-service.yml +++ b/config/nacos/local/file-service.yml @@ -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:} diff --git a/config/nacos/local/firstleg-service.yml b/config/nacos/local/firstleg-service.yml index 2597cf547..1fa897405 100644 --- a/config/nacos/local/firstleg-service.yml +++ b/config/nacos/local/firstleg-service.yml @@ -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} diff --git a/deploy/sql/2026-08-03-二期入库表结构-TEST环境一键执行.sql b/deploy/sql/2026-08-03-二期入库表结构-TEST环境一键执行.sql new file mode 100644 index 000000000..8ed4b5c61 --- /dev/null +++ b/deploy/sql/2026-08-03-二期入库表结构-TEST环境一键执行.sql @@ -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; diff --git a/file/src/main/java/com/baiyue/file/client/WeightOcrClient.java b/file/src/main/java/com/baiyue/file/client/WeightOcrClient.java new file mode 100644 index 000000000..b0528236a --- /dev/null +++ b/file/src/main/java/com/baiyue/file/client/WeightOcrClient.java @@ -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 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) {} +} diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformMeasurementImageController.java b/file/src/main/java/com/baiyue/file/controller/PlatformMeasurementImageController.java new file mode 100644 index 000000000..54988ec9b --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/PlatformMeasurementImageController.java @@ -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 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 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 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"); + } + } +} diff --git a/file/src/main/java/com/baiyue/file/entity/FileObjectEntity.java b/file/src/main/java/com/baiyue/file/entity/FileObjectEntity.java new file mode 100644 index 000000000..c65be7d56 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/entity/FileObjectEntity.java @@ -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; +} diff --git a/file/src/main/java/com/baiyue/file/mapper/FileObjectMapper.java b/file/src/main/java/com/baiyue/file/mapper/FileObjectMapper.java new file mode 100644 index 000000000..729476614 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/mapper/FileObjectMapper.java @@ -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 { +} diff --git a/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java b/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java new file mode 100644 index 000000000..52193661f --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java @@ -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() + .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); + } + } +} diff --git a/file/src/main/java/com/baiyue/file/service/MeasurementImageStorage.java b/file/src/main/java/com/baiyue/file/service/MeasurementImageStorage.java new file mode 100644 index 000000000..4a6078366 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/MeasurementImageStorage.java @@ -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 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) {} +} diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index 132249f7e..70210faa0 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -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 diff --git a/file/src/test/java/com/baiyue/file/controller/PlatformMeasurementImageControllerContractTest.java b/file/src/test/java/com/baiyue/file/controller/PlatformMeasurementImageControllerContractTest.java new file mode 100644 index 000000000..a4233746d --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/PlatformMeasurementImageControllerContractTest.java @@ -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); + } +} diff --git a/firstleg/pom.xml b/firstleg/pom.xml index 467108e07..55bed9506 100644 --- a/firstleg/pom.xml +++ b/firstleg/pom.xml @@ -14,6 +14,8 @@ com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config com.alibaba.cloudspring-cloud-starter-alibaba-sentinel + org.springframework.cloudspring-cloud-starter-openfeign + org.springframework.cloudspring-cloud-starter-loadbalancer com.alibaba.cloudspring-cloud-starter-stream-rocketmq org.springframework.bootspring-boot-starter-actuator com.mysqlmysql-connector-jruntime @@ -21,4 +23,3 @@ org.springframework.bootspring-boot-maven-plugin - diff --git a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java index 8532d169c..363d0c7eb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java @@ -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); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java new file mode 100644 index 000000000..7c2b5539c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java @@ -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 reserve(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody ReserveRequest request); + + @PostMapping("/confirm") + ApiResponse confirm(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody ConfirmRequest request); + + @PostMapping("/release") + ApiResponse> 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 allocations) {} + record ActiveResource(long reservationId, long warehouseId, long palletId, String palletCode, + long locationId, String locationCode, String state) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/config/InboundProperties.java b/firstleg/src/main/java/com/baiyue/firstleg/config/InboundProperties.java new file mode 100644 index 000000000..6bb5ee674 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/config/InboundProperties.java @@ -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); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/config/MeasurementOpenApiDocumentationConfiguration.java b/firstleg/src/main/java/com/baiyue/firstleg/config/MeasurementOpenApiDocumentationConfiguration.java new file mode 100644 index 000000000..f5cbe5e91 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/config/MeasurementOpenApiDocumentationConfiguration.java @@ -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(); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/config/MeasurementOpenApiProperties.java b/firstleg/src/main/java/com/baiyue/firstleg/config/MeasurementOpenApiProperties.java new file mode 100644 index 000000000..bb0776a82 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/config/MeasurementOpenApiProperties.java @@ -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 apps = new LinkedHashMap<>(); + + @Getter + @Setter + public static class AppCredential { + private boolean enabled = true; + private String secret; + private Long tenantId; + private Long subjectId; + private Map warehouseBindings = new LinkedHashMap<>(); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java new file mode 100644 index 000000000..0e21241e2 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java @@ -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 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)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementController.java new file mode 100644 index 000000000..a583bdfc1 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementController.java @@ -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 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 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))); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java new file mode 100644 index 000000000..6ee644800 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java @@ -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> 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 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 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 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> 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 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 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 retireRate(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, @PathVariable long id) { + service.retireRate(FirstlegScopeGuard.platform(user, tenantId), id, user.userId()); + return ApiResponse.success(null); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java new file mode 100644 index 000000000..a4fd76295 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java @@ -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 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 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 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 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 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)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java new file mode 100644 index 000000000..f92e750d9 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -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 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 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 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 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 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 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, "必须指定有效目标租户和操作人"); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java new file mode 100644 index 000000000..ec4be5c2f --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java @@ -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 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 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 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> 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, "必须指定有效目标租户"); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java new file mode 100644 index 000000000..24e8b2b8d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java @@ -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> 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 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, "必须指定有效目标租户"); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundListTab.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundListTab.java new file mode 100644 index 000000000..a8a1e3e4d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundListTab.java @@ -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 processStatuses; + + InboundListTab(List processStatuses) { + this.processStatuses = processStatuses; + } + + public List processStatuses() { + return processStatuses; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java new file mode 100644 index 000000000..9087c4414 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java @@ -0,0 +1,14 @@ +package com.baiyue.firstleg.domain; + +import java.util.Set; + +/** 二期入库主流程状态,不包含原型中的“待客户确认”。 */ +public final class InboundProcessStatus { + public static final Set VALUES = Set.of( + "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", + "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", "VOIDED" + ); + + private InboundProcessStatus() { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ExceptionReleaseRequest.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ExceptionReleaseRequest.java new file mode 100644 index 000000000..fdfd7c5cd --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ExceptionReleaseRequest.java @@ -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 +) { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java new file mode 100644 index 000000000..528038655 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java @@ -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 + ) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java new file mode 100644 index 000000000..a5de64c5e --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java @@ -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 + ) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java new file mode 100644 index 000000000..fa496b7e6 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java @@ -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 items, + List 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) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderDetail.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderDetail.java new file mode 100644 index 000000000..c511b95e0 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderDetail.java @@ -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 palletLocations, + List files, + List internalNotes, + List 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) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderQuery.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderQuery.java new file mode 100644 index 000000000..41447c6db --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderQuery.java @@ -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; + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java new file mode 100644 index 000000000..5e7510025 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java @@ -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() + ); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java new file mode 100644 index 000000000..c4b631e0f --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java @@ -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 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 + ) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementReviewRequest.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementReviewRequest.java new file mode 100644 index 000000000..23d1e9b14 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementReviewRequest.java @@ -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 +) { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java new file mode 100644 index 000000000..bcef6954a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java @@ -0,0 +1,7 @@ +package com.baiyue.firstleg.dto; + +import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; + +public record MerchantInboundBillingResponse(InboundOrderDetail inbound, + FeeSnapshotResponse latestFee) { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/OpenApiMeasurementDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/OpenApiMeasurementDtos.java new file mode 100644 index 000000000..741950b85 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/OpenApiMeasurementDtos.java @@ -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) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java new file mode 100644 index 000000000..9dd81bd6a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerChargeRuleEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerChargeRuleEntity.java new file mode 100644 index 000000000..681238e2e --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerChargeRuleEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java new file mode 100644 index 000000000..9ab280b6e --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeItemEntity.java new file mode 100644 index 000000000..454fa99cb --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeItemEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFileEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFileEntity.java new file mode 100644 index 000000000..b31673ff2 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFileEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundIdempotencyEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundIdempotencyEntity.java new file mode 100644 index 000000000..508805b2a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundIdempotencyEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundInternalNoteEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundInternalNoteEntity.java new file mode 100644 index 000000000..e8ca178d0 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundInternalNoteEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementEntity.java new file mode 100644 index 000000000..ae9816be7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java new file mode 100644 index 000000000..5d59e70cd --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java new file mode 100644 index 000000000..d1c5799dc --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderLookupEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderLookupEntity.java new file mode 100644 index 000000000..6c8d9e4f3 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderLookupEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOutboxEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOutboxEntity.java new file mode 100644 index 000000000..f796e244e --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOutboxEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPalletAllocationEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPalletAllocationEntity.java new file mode 100644 index 000000000..e96a536f2 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPalletAllocationEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java new file mode 100644 index 000000000..396695c78 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingIdempotencyEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingIdempotencyEntity.java new file mode 100644 index 000000000..79b45762e --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingIdempotencyEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundStatusHistoryEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundStatusHistoryEntity.java new file mode 100644 index 000000000..c9b5e23a2 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundStatusHistoryEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundTransportSnapshotEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundTransportSnapshotEntity.java new file mode 100644 index 000000000..2a8527d7b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundTransportSnapshotEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightResultEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightResultEntity.java new file mode 100644 index 000000000..6e00d2919 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightResultEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightRevisionEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightRevisionEntity.java new file mode 100644 index 000000000..7882ebdef --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightRevisionEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/MeasureReviewRecordEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/MeasureReviewRecordEntity.java new file mode 100644 index 000000000..6d17f2d70 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/MeasureReviewRecordEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ThirdPartyCallLogEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ThirdPartyCallLogEntity.java new file mode 100644 index 000000000..8fa99b565 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ThirdPartyCallLogEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/CarrierFbaRateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/CarrierFbaRateMapper.java new file mode 100644 index 000000000..466b1293a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/CarrierFbaRateMapper.java @@ -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 {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/CustomerChargeRuleMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/CustomerChargeRuleMapper.java new file mode 100644 index 000000000..e84d8c940 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/CustomerChargeRuleMapper.java @@ -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 {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundAllocationQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundAllocationQueryMapper.java new file mode 100644 index 000000000..d74fed0a3 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundAllocationQueryMapper.java @@ -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); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundFeeItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundFeeItemMapper.java new file mode 100644 index 000000000..a009a3db9 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundFeeItemMapper.java @@ -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 {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundFeeMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundFeeMapper.java new file mode 100644 index 000000000..cba0e3166 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundFeeMapper.java @@ -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 { + @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); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundFileMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundFileMapper.java new file mode 100644 index 000000000..bafe8e505 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundFileMapper.java @@ -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 { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundIdempotencyMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundIdempotencyMapper.java new file mode 100644 index 000000000..c391c472a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundIdempotencyMapper.java @@ -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 { + @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); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundInternalNoteMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundInternalNoteMapper.java new file mode 100644 index 000000000..f24c6dfe7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundInternalNoteMapper.java @@ -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 { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundMeasurementItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundMeasurementItemMapper.java new file mode 100644 index 000000000..a5e8989fd --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundMeasurementItemMapper.java @@ -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 { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundMeasurementMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundMeasurementMapper.java new file mode 100644 index 000000000..c0f0e1df8 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundMeasurementMapper.java @@ -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 { + @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); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderLookupMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderLookupMapper.java new file mode 100644 index 000000000..786a87dd8 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderLookupMapper.java @@ -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 { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java new file mode 100644 index 000000000..c34ba8f50 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java @@ -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 { + @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); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOutboxMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOutboxMapper.java new file mode 100644 index 000000000..e65c1342d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOutboxMapper.java @@ -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 { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPalletAllocationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPalletAllocationMapper.java new file mode 100644 index 000000000..8bc179064 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPalletAllocationMapper.java @@ -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 { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPricingIdempotencyMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPricingIdempotencyMapper.java new file mode 100644 index 000000000..6c9b55837 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPricingIdempotencyMapper.java @@ -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 {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPricingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPricingMapper.java new file mode 100644 index 000000000..cf19e0017 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPricingMapper.java @@ -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 {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundStatusHistoryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundStatusHistoryMapper.java new file mode 100644 index 000000000..28b6cf0ce --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundStatusHistoryMapper.java @@ -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 { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundTransportSnapshotMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundTransportSnapshotMapper.java new file mode 100644 index 000000000..b805b7473 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundTransportSnapshotMapper.java @@ -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 { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundWeightResultMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundWeightResultMapper.java new file mode 100644 index 000000000..14defce6a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundWeightResultMapper.java @@ -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 { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundWeightRevisionMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundWeightRevisionMapper.java new file mode 100644 index 000000000..7388636c6 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundWeightRevisionMapper.java @@ -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 { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/MeasureReviewRecordMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/MeasureReviewRecordMapper.java new file mode 100644 index 000000000..3571b647f --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/MeasureReviewRecordMapper.java @@ -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 { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ThirdPartyCallLogMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ThirdPartyCallLogMapper.java new file mode 100644 index 000000000..191af021c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ThirdPartyCallLogMapper.java @@ -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 { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java new file mode 100644 index 000000000..0cfa84006 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -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() + .eq(CustomerChargeRuleEntity::getTenantId, tenantId) + .eq(CustomerChargeRuleEntity::getId, id)); + } + + public CarrierFbaRateEntity findRate(long tenantId, long id) { + return rates.selectOne(new LambdaQueryWrapper() + .eq(CarrierFbaRateEntity::getTenantId, tenantId) + .eq(CarrierFbaRateEntity::getId, id)); + } + + public List listRules(long tenantId, String ruleCode) { + return rules.selectList(new LambdaQueryWrapper() + .eq(CustomerChargeRuleEntity::getTenantId, tenantId) + .eq(ruleCode != null && !ruleCode.isBlank(), CustomerChargeRuleEntity::getRuleCode, ruleCode) + .orderByAsc(CustomerChargeRuleEntity::getRuleCode) + .orderByDesc(CustomerChargeRuleEntity::getRuleVersion)); + } + + public List listRates(long tenantId, String rateCode) { + return rates.selectList(new LambdaQueryWrapper() + .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 values = listRules(tenantId, ruleCode); + return values.isEmpty() ? 1 : values.get(0).getRuleVersion() + 1; + } + + public int nextRateVersion(long tenantId, String rateCode) { + List 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() + .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() + .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() + .eq(InboundPricingEntity::getTenantId, tenantId) + .eq(InboundPricingEntity::getId, inboundId)); + } + + public List matchRules(InboundPricingEntity inbound, LocalDateTime at) { + return rules.selectList(new LambdaQueryWrapper() + .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 matchRates(InboundPricingEntity inbound, String chargeUnit, + LocalDateTime at) { + return rates.selectList(new LambdaQueryWrapper() + .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() + .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() + .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() + .eq(InboundFeeEntity::getTenantId, tenantId).eq(InboundFeeEntity::getId, feeId)); + } + + public InboundFeeEntity findLatestFee(long tenantId, long inboundId) { + return fees.selectOne(new LambdaQueryWrapper() + .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() + .eq(InboundFeeItemEntity::getTenantId, tenantId) + .eq(InboundFeeItemEntity::getFeeId, feeId) + .eq(InboundFeeItemEntity::getFeeCode, "FBA_BASE")); + } + + public List listItems(long tenantId, long feeId) { + return items.selectList(new LambdaQueryWrapper() + .eq(InboundFeeItemEntity::getTenantId, tenantId) + .eq(InboundFeeItemEntity::getFeeId, feeId) + .orderByAsc(InboundFeeItemEntity::getItemNo)); + } + + public int supersedeSent(long tenantId, long feeId) { + return fees.update(null, new LambdaUpdateWrapper() + .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 update = new LambdaUpdateWrapper() + .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() + .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() + .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() + .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() + .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() + .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() + .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); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementOpenApiRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementOpenApiRepository.java new file mode 100644 index 000000000..df5fcd597 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementOpenApiRepository.java @@ -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() + .eq(InboundOrderLookupEntity::getTenantId, tenantId) + .eq(InboundOrderLookupEntity::getInboundCode, inboundCode) + .eq(InboundOrderLookupEntity::getDeleted, 0)); + } + + public InboundOrderLookupEntity findInbound(long tenantId, long inboundId) { + return inboundOrders.selectOne(new LambdaQueryWrapper() + .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() + .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); + } + +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java new file mode 100644 index 000000000..6eb622b6b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java @@ -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() + .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() + .eq(InboundMeasurementEntity::getTenantId, tenantId) + .eq(InboundMeasurementEntity::getInboundId, inboundId) + .eq(InboundMeasurementEntity::getId, measurementId)); + } + + public InboundMeasurementEntity findLatestMeasurement(long tenantId, long inboundId) { + return measurements.selectOne(new LambdaQueryWrapper() + .eq(InboundMeasurementEntity::getTenantId, tenantId) + .eq(InboundMeasurementEntity::getInboundId, inboundId) + .orderByDesc(InboundMeasurementEntity::getCreatedAt) + .last("LIMIT 1")); + } + + public List listItems(long tenantId, long measurementId) { + return items.selectList(new LambdaQueryWrapper() + .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 replacement) { + items.delete(new LambdaQueryWrapper() + .eq(InboundMeasurementItemEntity::getTenantId, tenantId) + .eq(InboundMeasurementItemEntity::getMeasurementId, measurementId)); + replacement.forEach(items::insert); + } + + public List listWeights(long tenantId, long inboundId) { + return weights.selectList(new LambdaQueryWrapper() + .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() + .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() + .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() + .eq(MeasureReviewRecordEntity::getTenantId, tenantId) + .eq(MeasureReviewRecordEntity::getInboundId, inboundId) + .orderByDesc(MeasureReviewRecordEntity::getReviewedAt) + .last("LIMIT 1")); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java new file mode 100644 index 000000000..01884e668 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java @@ -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 page(long tenantId, InboundOrderQuery query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .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 result = orders.selectPage(new Page<>(query.page(), query.size()), wrapper); + List ids = result.getRecords().stream().map(InboundOrderEntity::getId).toList(); + Map transportByInbound = transportMap(tenantId, ids); + Map locationCodesByInbound = locationCodesMap(tenantId, ids); + List 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() + .eq(InboundOrderEntity::getTenantId, tenantId) + .eq(InboundOrderEntity::getId, inboundId)); + } + + public InboundOrderEntity findByOrderId(long tenantId, long orderId) { + return orders.selectOne(new LambdaQueryWrapper() + .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 palletAllocations = pallets.selectList( + owned(InboundPalletAllocationEntity::getTenantId, + InboundPalletAllocationEntity::getInboundId, tenantId, inboundId) + .orderByAsc(InboundPalletAllocationEntity::getAllocationNo)); + List 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 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 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 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 transportMap(long tenantId, List inboundIds) { + if (inboundIds.isEmpty()) { + return Map.of(); + } + return transports.selectList(new LambdaQueryWrapper() + .eq(InboundTransportSnapshotEntity::getTenantId, tenantId) + .in(InboundTransportSnapshotEntity::getInboundId, inboundIds)) + .stream().collect(Collectors.toMap(InboundTransportSnapshotEntity::getInboundId, + Function.identity(), (left, right) -> left)); + } + + private Map locationCodesMap(long tenantId, List inboundIds) { + if (inboundIds.isEmpty()) { + return Map.of(); + } + return pallets.selectList(new LambdaQueryWrapper() + .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 LambdaQueryWrapper owned( + com.baomidou.mybatisplus.core.toolkit.support.SFunction tenantColumn, + com.baomidou.mybatisplus.core.toolkit.support.SFunction inboundColumn, + long tenantId, + long inboundId) { + return new LambdaQueryWrapper().eq(tenantColumn, tenantId).eq(inboundColumn, inboundId); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java new file mode 100644 index 000000000..6279b5acd --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java @@ -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() + .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; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundWorkflowRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundWorkflowRepository.java new file mode 100644 index 000000000..20685a3b8 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundWorkflowRepository.java @@ -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() + .eq(InboundOrderEntity::getTenantId, tenantId) + .eq(InboundOrderEntity::getId, inboundId)); + } + + public InboundMeasurementEntity findMeasurement(long tenantId, long inboundId, long measurementId) { + return measurements.selectOne(new LambdaQueryWrapper() + .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 reviewHistory( + long tenantId, long inboundId, long page, long size) { + return PageResult.from(reviews.selectPage(new Page<>(page, size), + new LambdaQueryWrapper() + .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() + .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() + .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() + .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); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/security/AuthenticatedMeasurementApp.java b/firstleg/src/main/java/com/baiyue/firstleg/security/AuthenticatedMeasurementApp.java new file mode 100644 index 000000000..8419bb0b0 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/security/AuthenticatedMeasurementApp.java @@ -0,0 +1,11 @@ +package com.baiyue.firstleg.security; + +import java.util.Map; + +/** 签名校验通过后的第三方应用主体。 */ +public record AuthenticatedMeasurementApp( + String appKey, + long tenantId, + long subjectId, + Map warehouseBindings) { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/security/MeasurementRequestAuthenticator.java b/firstleg/src/main/java/com/baiyue/firstleg/security/MeasurementRequestAuthenticator.java new file mode 100644 index 000000000..b91f44ab6 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/security/MeasurementRequestAuthenticator.java @@ -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); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/security/MeasurementRequestHeaders.java b/firstleg/src/main/java/com/baiyue/firstleg/security/MeasurementRequestHeaders.java new file mode 100644 index 000000000..6b625139d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/security/MeasurementRequestHeaders.java @@ -0,0 +1,10 @@ +package com.baiyue.firstleg.security; + +/** 第三方测量请求的认证和幂等请求头。 */ +public record MeasurementRequestHeaders( + String appKey, + String timestamp, + String nonce, + String signature, + String idempotencyKey) { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/security/NonceReplayGuard.java b/firstleg/src/main/java/com/baiyue/firstleg/security/NonceReplayGuard.java new file mode 100644 index 000000000..034bd8b49 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/security/NonceReplayGuard.java @@ -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); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/security/RedisNonceReplayGuard.java b/firstleg/src/main/java/com/baiyue/firstleg/security/RedisNonceReplayGuard.java new file mode 100644 index 000000000..f07f53dd4 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/security/RedisNonceReplayGuard.java @@ -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); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/AutomaticInboundCompletionService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/AutomaticInboundCompletionService.java new file mode 100644 index 000000000..7b35b6778 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/AutomaticInboundCompletionService.java @@ -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; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java new file mode 100644 index 000000000..417e1b94a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java @@ -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 listRules(long tenantId, String code) { + return repository.listRules(tenantId, code); + } + + public List 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); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java new file mode 100644 index 000000000..a144ff121 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -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 matchedRules = repository.matchRules(inbound, pricingAt); + CustomerChargeRuleEntity rule = unique(matchedRules, "客户计费规则"); + List 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 unique(List 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); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegScopeGuard.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegScopeGuard.java new file mode 100644 index 000000000..70645f2ef --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegScopeGuard.java @@ -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; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java new file mode 100644 index 000000000..560ca24cb --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java @@ -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; + +/** + * 发送费用并完成入库的状态编排骨架。 + * + *

本服务不计算费用、不创建托盘/库位,也不调用仓库 CRUD。测量完成阶段已确认托盘和库位占用; + * 客服审核通过后的自动编排调用 {@link #beginSendFeeAndInbound} 进入 INBOUNDING,再调用 + * {@link #completeSendFeeAndInbound} 发布费用并完成入库。

+ */ +@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, "入库单已被并发更新,请刷新后重试"); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundIdempotencyService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundIdempotencyService.java new file mode 100644 index 000000000..29b7a6488 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundIdempotencyService.java @@ -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) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java new file mode 100644 index 000000000..3d4ba704e --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java @@ -0,0 +1,236 @@ +package com.baiyue.firstleg.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.CallbackRequest; +import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.EventResponse; +import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.Item; +import com.baiyue.firstleg.entity.InboundMeasurementEntity; +import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; +import com.baiyue.firstleg.entity.InboundOrderLookupEntity; +import com.baiyue.firstleg.repository.InboundMeasurementOpenApiRepository; +import com.baiyue.firstleg.security.AuthenticatedMeasurementApp; +import com.baiyue.firstleg.security.MeasurementRequestAuthenticator; +import com.baiyue.firstleg.security.MeasurementRequestHeaders; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validator; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Set; + +/** + * 第三方测量接收编排。只写调用日志、测量批次和明细,不提交审核且不更新入库主单。 + */ +@Service +public class InboundMeasurementOpenApiService { + static final String CALLBACK_PATH = "/openapi/v1/inbound-measurements"; + private static final Set ACCEPTABLE_INBOUND_STATUSES = Set.of("WAIT_MEASURE", "MEASURE_REJECTED"); + private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000"); + private static final String CALCULATION_RULE_VERSION = "V1_CEILING_4"; + + private final InboundMeasurementOpenApiRepository repository; + private final MeasurementRequestAuthenticator authenticator; + private final ThirdPartyCallLogService callLogService; + private final ObjectMapper objectMapper; + private final Validator validator; + private final TransactionTemplate transactionTemplate; + + public InboundMeasurementOpenApiService(InboundMeasurementOpenApiRepository repository, + MeasurementRequestAuthenticator authenticator, + ThirdPartyCallLogService callLogService, + ObjectMapper objectMapper, Validator validator, + TransactionTemplate transactionTemplate) { + this.repository = repository; + this.authenticator = authenticator; + this.callLogService = callLogService; + this.objectMapper = objectMapper; + this.validator = validator; + this.transactionTemplate = transactionTemplate; + } + + public EventResponse receive(MeasurementRequestHeaders headers, String rawBody) { + long logId = callLogService.start("POST", CALLBACK_PATH, headers, rawBody); + try { + AuthenticatedMeasurementApp app = authenticator.authenticate( + "POST", CALLBACK_PATH, headers, rawBody, true); + CallbackRequest request = parseAndValidate(rawBody); + String requestHash = MeasurementRequestAuthenticator.sha256Hex(rawBody); + if (!callLogService.claimIdempotency(logId)) { + EventResponse replay = replayIdempotency(app, headers.idempotencyKey(), requestHash); + callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "duplicate", writeJson(replay)); + return replay; + } + EventResponse result = transactionTemplate.execute(status -> + persist(app, request, rawBody, requestHash)); + String resultJson = writeJson(result); + callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "success", resultJson); + return result; + } catch (RuntimeException exception) { + ResultCode code = exception instanceof BizException biz ? biz.getResultCode() : ResultCode.INTERNAL_ERROR; + callLogService.complete(logId, "REJECTED", code.code(), exception.getMessage(), null); + throw exception; + } + } + + public EventResponse query(String eventNo, MeasurementRequestHeaders headers) { + String path = CALLBACK_PATH + "/events/" + eventNo; + long logId = callLogService.start("GET", path, headers, ""); + try { + AuthenticatedMeasurementApp app = authenticator.authenticate("GET", path, headers, "", false); + InboundMeasurementEntity measurement = repository.findEvent(app.tenantId(), app.appKey(), eventNo); + if (measurement == null) { + throw new BizException(ResultCode.NOT_FOUND, "第三方测量事件不存在"); + } + InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), measurement.getInboundId()); + if (inbound == null) { + throw new BizException(ResultCode.NOT_FOUND, "入仓码对应的入库单不存在"); + } + EventResponse result = response(measurement, inbound.getInboundCode()); + callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "success", writeJson(result)); + return result; + } catch (RuntimeException exception) { + ResultCode code = exception instanceof BizException biz ? biz.getResultCode() : ResultCode.INTERNAL_ERROR; + callLogService.complete(logId, "REJECTED", code.code(), exception.getMessage(), null); + throw exception; + } + } + + private EventResponse persist(AuthenticatedMeasurementApp app, + CallbackRequest request, String rawBody, String requestHash) { + InboundMeasurementEntity existingEvent = repository.findEvent( + app.tenantId(), app.appKey(), request.eventNo()); + if (existingEvent != null) { + if (!MeasurementRequestAuthenticator.sha256Hex(existingEvent.getRawPayloadJson()).equals(requestHash)) { + throw new BizException(ResultCode.CONFLICT, "同一 appKey 和 eventNo 的报文内容不一致"); + } + InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), existingEvent.getInboundId()); + EventResponse result = response(existingEvent, inbound == null ? request.inboundCode() : inbound.getInboundCode()); + return result; + } + + Long boundWarehouseId = app.warehouseBindings().get(request.warehouseCode()); + if (boundWarehouseId == null) { + throw new BizException(ResultCode.FORBIDDEN, "应用未绑定请求中的仓库编码"); + } + InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), request.inboundCode()); + if (inbound == null || !boundWarehouseId.equals(inbound.getWarehouseId())) { + throw new BizException(ResultCode.NOT_FOUND, "入仓码不存在或不属于绑定仓库"); + } + if (!ACCEPTABLE_INBOUND_STATUSES.contains(inbound.getProcessStatus())) { + throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许接收测量结果"); + } + + InboundMeasurementEntity measurement = buildMeasurement(app, request, rawBody, inbound.getId()); + repository.insertMeasurement(measurement); + for (Item item : request.items()) { + repository.insertItem(buildItem(app.tenantId(), measurement.getId(), item)); + } + EventResponse result = response(measurement, request.inboundCode()); + return result; + } + + private InboundMeasurementEntity buildMeasurement(AuthenticatedMeasurementApp app, + CallbackRequest request, String rawBody, long inboundId) { + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(IdWorker.getId()); + measurement.setTenantId(app.tenantId()); + measurement.setInboundId(inboundId); + measurement.setSourceType("THIRD_PARTY"); + measurement.setSourceCode(app.appKey()); + measurement.setExternalEventNo(request.eventNo()); + measurement.setDeviceCode(request.deviceCode()); + measurement.setWarehouseCode(request.warehouseCode()); + measurement.setMeasuredAt(request.measuredAt().withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime()); + measurement.setStatus("RECEIVED"); + measurement.setRawPayloadJson(rawBody); + measurement.setTotalPackageCount(request.items().stream().mapToInt(Item::quantity).sum()); + BigDecimal rawTotal = request.items().stream().map(this::rawVolume).reduce(BigDecimal.ZERO, BigDecimal::add); + measurement.setTotalVolumeRawCbm(rawTotal.setScale(6, RoundingMode.HALF_UP)); + measurement.setTotalVolumeCbm(rawTotal.setScale(4, RoundingMode.CEILING)); + measurement.setCreatedBy(app.subjectId()); + measurement.setCreatedAt(LocalDateTime.now()); + measurement.setUpdatedAt(LocalDateTime.now()); + return measurement; + } + + private InboundMeasurementItemEntity buildItem(long tenantId, long measurementId, Item source) { + BigDecimal rawVolume = rawVolume(source); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setId(IdWorker.getId()); + item.setTenantId(tenantId); + item.setMeasurementId(measurementId); + item.setItemNo(source.itemNo()); + item.setBoxSpecification(org.springframework.util.StringUtils.hasText(source.boxSpecification()) + ? source.boxSpecification().trim() : source.itemNo()); + item.setPackageCount(source.quantity()); + item.setLengthCm(source.lengthCm()); + item.setWidthCm(source.widthCm()); + item.setHeightCm(source.heightCm()); + item.setVolumeRawCbm(rawVolume.setScale(6, RoundingMode.HALF_UP)); + item.setVolumeCbm(rawVolume.setScale(4, RoundingMode.CEILING)); + item.setCalculationRuleVersion(CALCULATION_RULE_VERSION); + item.setCreatedAt(LocalDateTime.now()); + return item; + } + + private BigDecimal rawVolume(Item item) { + return item.lengthCm().multiply(item.widthCm()).multiply(item.heightCm()) + .multiply(BigDecimal.valueOf(item.quantity())) + .divide(CUBIC_CENTIMETERS_PER_CBM, 12, RoundingMode.HALF_UP); + } + + private EventResponse replayIdempotency(AuthenticatedMeasurementApp app, String key, String requestHash) { + var existing = callLogService.findIdempotencyOwner(app.tenantId(), app.appKey(), key); + if (existing == null || "PROCESSING".equals(existing.getProcessingStatus())) { + throw new BizException(ResultCode.CONFLICT, "相同 Idempotency-Key 的请求正在处理"); + } + if (!requestHash.equals(existing.getRequestHash())) { + throw new BizException(ResultCode.CONFLICT, "同一 Idempotency-Key 的报文内容不一致"); + } + if (!"ACCEPTED".equals(existing.getProcessingStatus()) || existing.getResultJson() == null) { + throw new BizException(ResultCode.CONFLICT, "同一 Idempotency-Key 已处理但未成功"); + } + try { + return objectMapper.readValue(existing.getResultJson(), EventResponse.class); + } catch (JsonProcessingException exception) { + throw new BizException(ResultCode.INTERNAL_ERROR, "幂等结果无法读取"); + } + } + + private CallbackRequest parseAndValidate(String rawBody) { + try { + CallbackRequest request = objectMapper.readValue(rawBody, CallbackRequest.class); + Set> violations = validator.validate(request); + if (!violations.isEmpty()) { + throw new BizException(ResultCode.VALIDATION_FAILED, + violations.iterator().next().getPropertyPath() + " " + violations.iterator().next().getMessage()); + } + return request; + } catch (BizException exception) { + throw exception; + } catch (JsonProcessingException exception) { + throw new BizException(ResultCode.BAD_REQUEST, "请求 JSON 格式无效"); + } + } + + private EventResponse response(InboundMeasurementEntity measurement, String inboundCode) { + return new EventResponse(measurement.getExternalEventNo(), inboundCode, true, + measurement.getStatus(), measurement.getCreatedAt().atOffset(ZoneOffset.UTC)); + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new BizException(ResultCode.INTERNAL_ERROR, "处理结果无法序列化"); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java new file mode 100644 index 000000000..e39f6662e --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -0,0 +1,395 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.InboundMeasurementRequests; +import com.baiyue.firstleg.dto.InboundMeasurementResponses; +import com.baiyue.firstleg.dto.MeasurementBatchCommand; +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.repository.InboundMeasurementRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** 平台端入库测量、OCR 修正、重量确认和提交审核应用服务。 */ +@Service +public class InboundMeasurementService { + private static final String STATUS_SUBMITTED = "SUBMITTED"; + private static final String WEIGHT_CONFIRMED = "CONFIRMED"; + private static final String WEIGHT_PENDING_CONFIRM = "PENDING_CONFIRM"; + private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL"; + /** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */ + private static final String RECOMMENDED_PALLET_TYPE = "STANDARD"; + private static final Set EDITABLE_INBOUND_STATUSES = + Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); + private static final BigDecimal LB_TO_KG = new BigDecimal("0.45359237"); + + private final InboundMeasurementRepository repository; + private final MeasurementBatchService batchService; + + public InboundMeasurementService(InboundMeasurementRepository repository, + MeasurementBatchService batchService) { + this.repository = repository; + this.batchService = batchService; + } + + public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId) { + InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + InboundMeasurementEntity measurement = repository.findLatestMeasurement(tenantId, inboundId); + List items = measurement == null + ? List.of() : repository.listItems(tenantId, measurement.getId()); + List weights = repository.listWeights(tenantId, inboundId); + MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inboundId); + return toDetail(inbound, measurement, items, weights, review); + } + + @Transactional + public InboundMeasurementResponses.DraftSaved saveDraft( + long tenantId, long inboundId, long operatorId, + InboundMeasurementRequests.SaveDraftRequest request) { + InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + requireEditable(inbound); + MeasurementBatchCommand command = new MeasurementBatchCommand( + tenantId, inboundId, request.measurementId(), request.version(), + MeasurementBatchService.SOURCE_MANUAL, "PLATFORM", null, null, null, + LocalDateTime.now(), null, request.originalDocumentNo(), + request.originalGrossWeightKg(), request.originalVolumeCbm(), request.actualGoodsName(), + operatorId, request.items().stream().map(item -> new MeasurementBatchCommand.Item( + item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(), + item.widthCm(), item.heightCm(), item.unitWeightKg(), item.intelligentImageFileId(), + item.overLimitWarning(), item.outerLengthCm(), item.outerWidthCm(), item.outerHeightCm(), + item.remark())).toList()); + InboundMeasurementEntity saved = batchService.saveBatch(command); + return new InboundMeasurementResponses.DraftSaved(saved.getId(), saved.getVersion(), saved.getStatus()); + } + + @Transactional + public InboundMeasurementResponses.MeasurementSubmitted submit( + long tenantId, long inboundId, long operatorId, + InboundMeasurementRequests.SubmitRequest request) { + InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + requireEditable(inbound); + InboundMeasurementEntity measurement = requireMeasurement(tenantId, inboundId, request.measurementId()); + requireVersion(request.version(), measurement.getVersion()); + if (!Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED) + .contains(measurement.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "当前测量批次不能重复提交审核"); + } + List items = repository.listItems(tenantId, measurement.getId()); + if (items.isEmpty()) { + throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细不能为空"); + } + if (items.stream().noneMatch(item -> item.getSourceImageFileId() != null)) { + throw new BizException(ResultCode.VALIDATION_FAILED, "至少需要一张测量图片"); + } + List weights = repository.listWeights(tenantId, inboundId); + BigDecimal confirmedWeight = confirmedWeightForSubmission(weights); + + measurement.setStatus(STATUS_SUBMITTED); + if (!repository.updateMeasurement(measurement)) { + throw concurrentModification(); + } + + inbound.setProcessStatus("MEASURE_PENDING"); + inbound.setMeasureReviewStatus("PENDING"); + inbound.setTotalPackageCount(measurement.getTotalPackageCount()); + inbound.setActualGrossWeightKg(confirmedWeight); + inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm()); + inbound.setVolumeWeightKg(measurement.getVolumeWeightKg()); + inbound.setMeasureCompletedAt(LocalDateTime.now()); + inbound.setUpdatedBy(operatorId); + if (!repository.updateInbound(inbound)) { + throw concurrentModification(); + } + return new InboundMeasurementResponses.MeasurementSubmitted( + measurement.getId(), measurement.getVersion(), inbound.getProcessStatus(), + inbound.getMeasureReviewStatus(), RECOMMENDED_PALLET_TYPE, null, null, null); + } + + @Transactional + public InboundMeasurementResponses.WeightResult createWeightResult( + long tenantId, long inboundId, long operatorId, + InboundMeasurementRequests.CreateWeightResultRequest request) { + InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + requireEditable(inbound); + if (repository.findWeightByFile(tenantId, inboundId, request.fileId()) != null) { + throw new BizException(ResultCode.CONFLICT, "同一称重图片不能重复计重"); + } + String unit = rawOcrUnit(request.ocrWeightUnit()); + InboundWeightResultEntity weight = new InboundWeightResultEntity(); + weight.setTenantId(tenantId); + weight.setInboundId(inboundId); + weight.setFileId(request.fileId()); + weight.setOcrProvider(request.ocrProvider()); + weight.setOcrRawText(request.ocrRawText()); + weight.setOcrWeightValue(request.ocrWeightValue()); + weight.setOcrWeightUnit(unit); + weight.setConfidence(request.confidence()); + weight.setRecognitionStatus(isPositive(request.ocrWeightValue()) && isSupportedUnit(unit) + ? WEIGHT_PENDING_CONFIRM : WEIGHT_NEEDS_MANUAL); + weight.setCreatedBy(operatorId); + weight.setUpdatedBy(operatorId); + weight.setDeleted(false); + weight.setVersion(0); + repository.insertWeight(weight); + return toWeight(weight); + } + + @Transactional + public InboundMeasurementResponses.WeightResult correctWeightResult( + long tenantId, long inboundId, long resultId, long operatorId, String operatorName, + InboundMeasurementRequests.CorrectWeightResultRequest request) { + InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + requireEditable(inbound); + InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId); + requireVersion(request.version(), weight.getVersion()); + String adjustedUnit = normalizeUnit(request.adjustedWeightUnit()); + BigDecimal beforeValue = effectiveValue(weight); + String beforeUnit = effectiveUnit(weight); + + weight.setAdjustedWeightValue(request.adjustedWeightValue()); + weight.setAdjustedWeightUnit(adjustedUnit); + weight.setAdjustmentReason(request.adjustmentReason()); + weight.setAdjustedBy(operatorId); + weight.setAdjustedAt(LocalDateTime.now()); + weight.setFinalWeightKg(null); + weight.setConfirmedBy(null); + weight.setConfirmedAt(null); + weight.setRecognitionStatus(WEIGHT_PENDING_CONFIRM); + weight.setUpdatedBy(operatorId); + if (!repository.updateWeight(weight)) { + throw concurrentModification(); + } + insertRevision(weight, request.version() + 1, "CORRECT", beforeValue, beforeUnit, + request.adjustedWeightValue(), adjustedUnit, null, request.adjustmentReason(), + operatorId, operatorName); + return toWeight(weight); + } + + @Transactional + public InboundMeasurementResponses.WeightResult confirmWeightResult( + long tenantId, long inboundId, long resultId, long operatorId, String operatorName, + InboundMeasurementRequests.ConfirmWeightResultRequest request) { + InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + requireEditable(inbound); + InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId); + requireVersion(request.version(), weight.getVersion()); + if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) { + return toWeight(weight); + } + BigDecimal value = effectiveValue(weight); + String rawUnit = effectiveUnit(weight); + if (!isPositive(value) || !StringUtils.hasText(rawUnit)) { + throw new BizException(ResultCode.VALIDATION_FAILED, "OCR 重量无法确认,请先人工修正数值和单位"); + } + String unit = normalizeUnit(rawUnit); + BigDecimal finalKg = toKg(value, unit); + weight.setFinalWeightKg(finalKg); + weight.setRecognitionStatus(WEIGHT_CONFIRMED); + weight.setConfirmedBy(operatorId); + weight.setConfirmedAt(LocalDateTime.now()); + weight.setUpdatedBy(operatorId); + if (!repository.updateWeight(weight)) { + throw concurrentModification(); + } + insertRevision(weight, request.version() + 1, "CONFIRM", value, unit, value, unit, + finalKg, null, operatorId, operatorName); + recalculateConfirmedWeight(inbound, operatorId); + return toWeight(weight); + } + + private void recalculateConfirmedWeight(InboundOrderEntity inbound, long operatorId) { + BigDecimal total = repository.listWeights(inbound.getTenantId(), inbound.getId()).stream() + .filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) + .map(InboundWeightResultEntity::getFinalWeightKg) + .reduce(BigDecimal.ZERO, BigDecimal::add) + .setScale(3, RoundingMode.HALF_UP); + inbound.setActualGrossWeightKg(total); + if ("WAIT_MEASURE".equals(inbound.getProcessStatus())) { + inbound.setProcessStatus("MEASURING"); + } + inbound.setUpdatedBy(operatorId); + if (!repository.updateInbound(inbound)) { + throw concurrentModification(); + } + } + + private BigDecimal confirmedWeightForSubmission(List weights) { + if (weights.isEmpty()) { + throw new BizException(ResultCode.VALIDATION_FAILED, "至少需要一张已确认的称重图片"); + } + if (weights.stream().anyMatch(weight -> !WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))) { + throw new BizException(ResultCode.VALIDATION_FAILED, "所有有效称重图片必须先完成确认"); + } + BigDecimal total = weights.stream().map(InboundWeightResultEntity::getFinalWeightKg) + .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(3, RoundingMode.HALF_UP); + if (!isPositive(total)) { + throw new BizException(ResultCode.VALIDATION_FAILED, "确认后的实际总重量必须大于 0"); + } + return total; + } + + private void insertRevision(InboundWeightResultEntity weight, int revisionNo, String action, + BigDecimal beforeValue, String beforeUnit, + BigDecimal afterValue, String afterUnit, BigDecimal finalWeightKg, + String reason, long operatorId, String operatorName) { + InboundWeightRevisionEntity revision = new InboundWeightRevisionEntity(); + revision.setTenantId(weight.getTenantId()); + revision.setInboundId(weight.getInboundId()); + revision.setWeightResultId(weight.getId()); + revision.setRevisionNo(revisionNo); + revision.setAction(action); + revision.setBeforeWeightValue(beforeValue); + revision.setBeforeWeightUnit(beforeUnit); + revision.setAfterWeightValue(afterValue); + revision.setAfterWeightUnit(afterUnit); + revision.setFinalWeightKg(finalWeightKg); + revision.setReason(reason); + revision.setOperatorId(operatorId); + revision.setOperatorName(operatorName); + revision.setOperatedAt(LocalDateTime.now()); + repository.insertRevision(revision); + } + + private InboundMeasurementResponses.Detail toDetail( + InboundOrderEntity inbound, InboundMeasurementEntity measurement, + List items, List weights, + MeasureReviewRecordEntity review) { + return new InboundMeasurementResponses.Detail( + inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(), + inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(), + RECOMMENDED_PALLET_TYPE, + review == null ? null : review.getReviewReason(), review == null ? null : review.getReviewerId(), + review == null ? null : review.getReviewerName(), measurement == null ? null : measurement.getId(), + measurement == null ? null : measurement.getSourceType(), + measurement == null ? null : measurement.getStatus(), + measurement == null ? null : measurement.getVersion(), + measurement == null ? null : measurement.getOriginalDocumentNo(), + measurement == null ? null : measurement.getOriginalGrossWeightKg(), + measurement == null ? null : measurement.getOriginalVolumeCbm(), + measurement == null ? inbound.getTotalPackageCount() : measurement.getTotalPackageCount(), + inbound.getActualGrossWeightKg(), + measurement == null ? inbound.getActualVolumeCbm() : measurement.getTotalVolumeCbm(), + measurement == null ? inbound.getVolumeWeightKg() : measurement.getVolumeWeightKg(), + measurement == null ? null : measurement.getActualGoodsName(), + items.stream().map(this::toItem).toList(), weights.stream().map(this::toWeight).toList()); + } + + private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item) { + return new InboundMeasurementResponses.Item( + item.getId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(), + item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getGirthCm(), + item.getUnitWeightKg(), item.getUnitVolumeCbm(), item.getTotalWeightKg(), + item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(), + item.getOverLimitWarning(), item.getOuterLengthCm(), item.getOuterWidthCm(), + item.getOuterHeightCm(), item.getRemark()); + } + + private InboundMeasurementResponses.WeightResult toWeight(InboundWeightResultEntity weight) { + return new InboundMeasurementResponses.WeightResult( + weight.getId(), weight.getFileId(), weight.getOcrProvider(), weight.getOcrRawText(), + weight.getOcrWeightValue(), weight.getOcrWeightUnit(), weight.getConfidence(), + weight.getAdjustedWeightValue(), weight.getAdjustedWeightUnit(), weight.getFinalWeightKg(), + weight.getRecognitionStatus(), weight.getAdjustmentReason(), weight.getAdjustedBy(), + weight.getAdjustedAt(), weight.getConfirmedBy(), weight.getConfirmedAt(), weight.getVersion()); + } + + private InboundOrderEntity requireInbound(long tenantId, long inboundId) { + InboundOrderEntity inbound = repository.findInbound(tenantId, inboundId); + if (inbound == null) { + throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); + } + return inbound; + } + + private InboundMeasurementEntity requireMeasurement(long tenantId, long inboundId, long measurementId) { + InboundMeasurementEntity measurement = repository.findMeasurement(tenantId, inboundId, measurementId); + if (measurement == null) { + throw new BizException(ResultCode.NOT_FOUND, "测量批次不存在"); + } + return measurement; + } + + private InboundWeightResultEntity requireWeight(long tenantId, long inboundId, long resultId) { + InboundWeightResultEntity weight = repository.findWeight(tenantId, inboundId, resultId); + if (weight == null) { + throw new BizException(ResultCode.NOT_FOUND, "OCR 重量结果不存在"); + } + return weight; + } + + private void requireEditable(InboundOrderEntity inbound) { + if (!EDITABLE_INBOUND_STATUSES.contains(inbound.getProcessStatus())) { + throw new BizException(ResultCode.CONFLICT, "当前入库状态不允许维护测量数据"); + } + } + + private void requireVersion(Integer expected, Integer actual) { + if (expected == null || !expected.equals(actual)) { + throw concurrentModification(); + } + } + + private String rawOcrUnit(String unit) { + if (!StringUtils.hasText(unit)) { + return null; + } + return unit.trim().toUpperCase(Locale.ROOT); + } + + private boolean isSupportedUnit(String unit) { + if (!StringUtils.hasText(unit)) { + return false; + } + String normalized = unit.trim().toUpperCase(Locale.ROOT); + return Set.of("KG", "KGS", "LB", "LBS").contains(normalized); + } + + private String normalizeUnit(String unit) { + String normalized = unit.trim().toUpperCase(Locale.ROOT); + if ("KGS".equals(normalized)) { + normalized = "KG"; + } else if ("LBS".equals(normalized)) { + normalized = "LB"; + } + if (!Set.of("KG", "LB").contains(normalized)) { + throw new BizException(ResultCode.VALIDATION_FAILED, "重量单位仅支持 KG 或 LB"); + } + return normalized; + } + + private BigDecimal toKg(BigDecimal value, String unit) { + BigDecimal kg = "LB".equals(unit) ? value.multiply(LB_TO_KG) : value; + return kg.setScale(3, RoundingMode.HALF_UP); + } + + private BigDecimal effectiveValue(InboundWeightResultEntity weight) { + return weight.getAdjustedWeightValue() == null + ? weight.getOcrWeightValue() : weight.getAdjustedWeightValue(); + } + + private String effectiveUnit(InboundWeightResultEntity weight) { + return weight.getAdjustedWeightUnit() == null + ? weight.getOcrWeightUnit() : weight.getAdjustedWeightUnit(); + } + + private boolean isPositive(BigDecimal value) { + return value != null && value.compareTo(BigDecimal.ZERO) > 0; + } + + private BizException concurrentModification() { + return new BizException(ResultCode.CONFLICT, "数据版本已变化,请刷新后重试"); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderQueryService.java new file mode 100644 index 000000000..e292290d6 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderQueryService.java @@ -0,0 +1,50 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.domain.InboundProcessStatus; +import com.baiyue.firstleg.dto.InboundOrderDetail; +import com.baiyue.firstleg.dto.InboundOrderQuery; +import com.baiyue.firstleg.dto.InboundOrderSummary; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.repository.InboundOrderRepository; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +/** 入库单列表和详情只读应用服务,不执行任何状态推进。 */ +@Service +public class InboundOrderQueryService { + private final InboundOrderRepository repository; + + public InboundOrderQueryService(InboundOrderRepository repository) { + this.repository = repository; + } + + public PageResult page(long tenantId, InboundOrderQuery query) { + validateQuery(query); + return repository.page(tenantId, query); + } + + public InboundOrderDetail detail(long tenantId, long inboundId) { + InboundOrderEntity order = repository.findOwned(tenantId, inboundId); + if (order == null) { + throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); + } + return repository.detail(tenantId, order); + } + + private void validateQuery(InboundOrderQuery query) { + if (StringUtils.hasText(query.processStatus()) + && !InboundProcessStatus.VALUES.contains(query.processStatus())) { + throw new BizException(ResultCode.BAD_REQUEST, "入库流程状态不合法"); + } + if (query.tab() != null && StringUtils.hasText(query.processStatus())) { + throw new BizException(ResultCode.BAD_REQUEST, "页签和流程状态不能同时指定"); + } + if (query.expectedArrivalFrom() != null && query.expectedArrivalTo() != null + && query.expectedArrivalFrom().isAfter(query.expectedArrivalTo())) { + throw new BizException(ResultCode.BAD_REQUEST, "预计到达开始时间不能晚于结束时间"); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java new file mode 100644 index 000000000..397fbe730 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -0,0 +1,184 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.MeasurementBatchCommand; +import com.baiyue.firstleg.entity.InboundMeasurementEntity; +import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; +import com.baiyue.firstleg.repository.InboundMeasurementRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * 后台录入和第三方回传共用的测量批次持久化服务。 + * + *

本服务只保存测量批次和明细,绝不推进入库单状态。第三方适配层应调用 + * {@link #saveBatch(MeasurementBatchCommand)},由平台确认接口另行提交审核。

+ */ +@Service +public class MeasurementBatchService { + static final String SOURCE_MANUAL = "MANUAL"; + static final String SOURCE_THIRD_PARTY = "THIRD_PARTY"; + static final String STATUS_DRAFT = "DRAFT"; + static final String STATUS_RECEIVED = "RECEIVED"; + static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167"); + static final String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH"; + + private final InboundMeasurementRepository repository; + + public MeasurementBatchService(InboundMeasurementRepository repository) { + this.repository = repository; + } + + @Transactional + public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) { + if (repository.findInbound(command.tenantId(), command.inboundId()) == null) { + throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); + } + validateSource(command); + List calculatedItems = calculateItems(command); + + InboundMeasurementEntity measurement; + if (command.measurementId() == null) { + measurement = newMeasurement(command); + applyTotals(measurement, calculatedItems); + repository.insertMeasurement(measurement); + } else { + measurement = requireEditableManualBatch(command); + applyHeader(measurement, command); + applyTotals(measurement, calculatedItems); + if (!repository.updateMeasurement(measurement)) { + throw concurrentModification(); + } + } + calculatedItems.forEach(item -> { + item.setTenantId(command.tenantId()); + item.setMeasurementId(measurement.getId()); + }); + repository.replaceItems(command.tenantId(), measurement.getId(), calculatedItems); + return measurement; + } + + private void validateSource(MeasurementBatchCommand command) { + if (!SOURCE_MANUAL.equals(command.sourceType()) && !SOURCE_THIRD_PARTY.equals(command.sourceType())) { + throw new BizException(ResultCode.BAD_REQUEST, "测量数据来源不支持"); + } + if (SOURCE_THIRD_PARTY.equals(command.sourceType()) + && (command.externalEventNo() == null || command.externalEventNo().isBlank())) { + throw new BizException(ResultCode.BAD_REQUEST, "第三方测量必须提供事件号"); + } + if (command.items() == null || command.items().isEmpty()) { + throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细不能为空"); + } + } + + private InboundMeasurementEntity newMeasurement(MeasurementBatchCommand command) { + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setTenantId(command.tenantId()); + measurement.setInboundId(command.inboundId()); + measurement.setSourceType(command.sourceType()); + measurement.setStatus(SOURCE_MANUAL.equals(command.sourceType()) ? STATUS_DRAFT : STATUS_RECEIVED); + measurement.setCreatedBy(command.operatorId()); + measurement.setVersion(0); + applyHeader(measurement, command); + return measurement; + } + + private InboundMeasurementEntity requireEditableManualBatch(MeasurementBatchCommand command) { + InboundMeasurementEntity measurement = repository.findMeasurement( + command.tenantId(), command.inboundId(), command.measurementId()); + if (measurement == null) { + throw new BizException(ResultCode.NOT_FOUND, "测量批次不存在"); + } + if (!SOURCE_MANUAL.equals(measurement.getSourceType()) || !STATUS_DRAFT.equals(measurement.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "仅人工草稿允许直接修改"); + } + if (!Integer.valueOf(command.expectedVersion()).equals(measurement.getVersion())) { + throw concurrentModification(); + } + return measurement; + } + + private void applyHeader(InboundMeasurementEntity measurement, MeasurementBatchCommand command) { + measurement.setSourceCode(command.sourceCode()); + measurement.setExternalEventNo(command.externalEventNo()); + measurement.setDeviceCode(command.deviceCode()); + measurement.setWarehouseCode(command.warehouseCode()); + measurement.setMeasuredAt(command.measuredAt()); + measurement.setRawPayloadJson(command.rawPayloadJson()); + measurement.setOriginalDocumentNo(command.originalDocumentNo()); + measurement.setOriginalGrossWeightKg(command.originalGrossWeightKg()); + measurement.setOriginalVolumeCbm(command.originalVolumeCbm()); + measurement.setActualGoodsName(command.actualGoodsName()); + } + + private List calculateItems(MeasurementBatchCommand command) { + Set itemNumbers = new HashSet<>(); + return command.items().stream().map(source -> { + if (!itemNumbers.add(source.itemNo())) { + throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细号不能重复"); + } + BigDecimal unitVolumeRaw = source.lengthCm().multiply(source.widthCm()) + .multiply(source.heightCm()).divide(new BigDecimal("1000000"), 6, RoundingMode.HALF_UP); + BigDecimal unitVolume = unitVolumeRaw.setScale(4, RoundingMode.CEILING); + BigDecimal packageCount = BigDecimal.valueOf(source.packageCount()); + BigDecimal totalVolumeRaw = unitVolumeRaw.multiply(packageCount).setScale(6, RoundingMode.HALF_UP); + BigDecimal totalVolume = unitVolume.multiply(packageCount).setScale(4, RoundingMode.CEILING); + + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setItemNo(source.itemNo()); + item.setBoxSpecification(source.boxSpecification()); + item.setPackageCount(source.packageCount()); + item.setLengthCm(source.lengthCm()); + item.setWidthCm(source.widthCm()); + item.setHeightCm(source.heightCm()); + item.setGirthCm(source.lengthCm().add(source.widthCm().add(source.heightCm()) + .multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING)); + item.setUnitWeightKg(source.unitWeightKg() == null ? null + : source.unitWeightKg().setScale(3, RoundingMode.CEILING)); + item.setUnitVolumeCbm(unitVolume); + item.setVolumeRawCbm(totalVolumeRaw); + item.setVolumeCbm(totalVolume); + item.setTotalWeightKg(source.unitWeightKg() == null ? null + : source.unitWeightKg().multiply(packageCount).setScale(3, RoundingMode.CEILING)); + item.setTotalVolumeCbm(totalVolume); + item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING)); + item.setCalculationRuleVersion(CALCULATION_RULE_VERSION); + item.setSourceImageFileId(source.intelligentImageFileId()); + item.setOverLimitWarning(source.overLimitWarning()); + item.setOuterLengthCm(source.outerLengthCm()); + item.setOuterWidthCm(source.outerWidthCm()); + item.setOuterHeightCm(source.outerHeightCm()); + item.setRemark(source.remark()); + item.setCreatedAt(LocalDateTime.now()); + return item; + }).toList(); + } + + private void applyTotals(InboundMeasurementEntity measurement, + List items) { + long packages = items.stream().mapToLong(InboundMeasurementItemEntity::getPackageCount).sum(); + if (packages > Integer.MAX_VALUE) { + throw new BizException(ResultCode.VALIDATION_FAILED, "测量总箱数超过系统上限"); + } + BigDecimal rawVolume = items.stream().map(InboundMeasurementItemEntity::getVolumeRawCbm) + .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(6, RoundingMode.HALF_UP); + BigDecimal volume = items.stream().map(InboundMeasurementItemEntity::getTotalVolumeCbm) + .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(4, RoundingMode.CEILING); + measurement.setTotalPackageCount((int) packages); + measurement.setTotalVolumeRawCbm(rawVolume); + measurement.setTotalVolumeCbm(volume); + measurement.setVolumeWeightKg(volume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING)); + } + + private BizException concurrentModification() { + return new BizException(ResultCode.CONFLICT, "测量数据已被其他人修改,请刷新后重试"); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java new file mode 100644 index 000000000..6879b4a54 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java @@ -0,0 +1,108 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.WarehousePalletLocationClient; +import com.baiyue.firstleg.config.InboundProperties; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.repository.InboundPalletAllocationRepository; +import com.baiyue.firstleg.repository.InboundWorkflowRepository; +import feign.FeignException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Service +public class MeasurementLocationOccupationService { + private static final String SOURCE_SERVICE = "firstleg-service"; + private final WarehousePalletLocationClient warehouse; + private final InboundPalletAllocationRepository allocations; + private final InboundWorkflowRepository workflow; + private final InboundProperties properties; + private final String internalToken; + + public MeasurementLocationOccupationService(WarehousePalletLocationClient warehouse, + InboundPalletAllocationRepository allocations, InboundWorkflowRepository workflow, + InboundProperties properties, + @Value("${baiyue.security.internal-service-token}") String internalToken) { + this.warehouse = warehouse; + this.allocations = allocations; + this.workflow = workflow; + this.properties = properties; + this.internalToken = internalToken; + } + + public void occupy(long tenantId, long inboundId, long operatorId, long measurementId, + int measurementVersion) { + InboundOrderEntity order = requireOrder(tenantId, inboundId); + long warehouseId = requireWarehouse(order); + BigDecimal volume = requireVolume(order); + String key = "measure-" + inboundId + "-" + measurementId + "-" + measurementVersion; + WarehousePalletLocationClient.BatchResult reserved = requireSuccess(call(() -> warehouse.reserve( + internalToken, new WarehousePalletLocationClient.ReserveRequest(tenantId, warehouseId, + SOURCE_SERVICE, inboundId, order.getInboundCode(), key, operatorId, volume, + properties.getDefaultPalletCapacityCbm(), "STANDARD", + LocalDateTime.now().plus(properties.getReservationTtl()), true))), + "测量完成后库位预占失败"); + allocations.replace(tenantId, inboundId, warehouseId, + properties.getDefaultPalletCapacityCbm(), reserved); + try { + WarehousePalletLocationClient.BatchResult occupied = requireSuccess(call(() -> warehouse.confirm( + internalToken, new WarehousePalletLocationClient.ConfirmRequest(tenantId, warehouseId, + SOURCE_SERVICE, inboundId, operatorId, volume, + properties.getDefaultPalletCapacityCbm()))), "测量完成后库位占用失败"); + allocations.replace(tenantId, inboundId, warehouseId, + properties.getDefaultPalletCapacityCbm(), occupied); + } catch (RuntimeException failure) { + try { + warehouse.release(internalToken, new WarehousePalletLocationClient.ReleaseRequest( + tenantId, SOURCE_SERVICE, inboundId, operatorId, "测量完成库位占用失败")); + } catch (RuntimeException releaseFailure) { + failure.addSuppressed(releaseFailure); + } + throw failure; + } + } + + private InboundOrderEntity requireOrder(long tenantId, long inboundId) { + InboundOrderEntity order = workflow.findOwned(tenantId, inboundId); + if (order == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); + return order; + } + + private static long requireWarehouse(InboundOrderEntity order) { + if (order.getWarehouseId() == null || order.getWarehouseId() <= 0) { + throw new BizException(ResultCode.CONFLICT, "入库单缺少收货仓库"); + } + return order.getWarehouseId(); + } + + private static BigDecimal requireVolume(InboundOrderEntity order) { + if (order.getActualVolumeCbm() == null || order.getActualVolumeCbm().signum() <= 0) { + throw new BizException(ResultCode.CONFLICT, "入库单缺少有效实测体积"); + } + return order.getActualVolumeCbm(); + } + + private static ApiResponse call(RemoteCall call) { + try { return call.invoke(); } + catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓储服务不可用"); + } + } + + private static T requireSuccess(ApiResponse response, String fallback) { + if (response == null || !response.successful() || response.data() == null) { + ResultCode code = response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code()); + throw new BizException(code, response == null || response.message() == null + ? fallback : response.message()); + } + return response.data(); + } + + @FunctionalInterface + private interface RemoteCall { ApiResponse invoke(); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java new file mode 100644 index 000000000..488b8b117 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java @@ -0,0 +1,190 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.config.InboundProperties; +import com.baiyue.firstleg.dto.ExceptionReleaseRequest; +import com.baiyue.firstleg.dto.MeasurementReviewRequest; +import com.baiyue.firstleg.entity.InboundMeasurementEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.entity.MeasureReviewRecordEntity; +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; + +/** + * 测量审核应用服务。审核是 MEASURE_PENDING 到计费阶段的唯一正常推进关口。 + */ +@Service +public class MeasurementReviewService { + private final InboundWorkflowRepository repository; + private final InboundIdempotencyService idempotency; + private final InboundProperties properties; + + public MeasurementReviewService(InboundWorkflowRepository repository, + InboundIdempotencyService idempotency, + InboundProperties properties) { + this.repository = repository; + this.idempotency = idempotency; + this.properties = properties; + } + + @Transactional + public void approve(long tenantId, long operatorId, String operatorName, long inboundId, + String idempotencyKey, MeasurementReviewRequest request) { + review(tenantId, operatorId, operatorName, inboundId, idempotencyKey, request, true); + } + + @Transactional + public void reject(long tenantId, long operatorId, String operatorName, long inboundId, + String idempotencyKey, MeasurementReviewRequest request) { + if (!StringUtils.hasText(request.reason())) { + throw new BizException(ResultCode.VALIDATION_FAILED, "审核驳回必须填写原因"); + } + review(tenantId, operatorId, operatorName, inboundId, idempotencyKey, request, false); + } + + private void review(long tenantId, long operatorId, String operatorName, long inboundId, + String idempotencyKey, MeasurementReviewRequest request, boolean approved) { + String operation = approved ? "MEASURE_APPROVE" : "MEASURE_REJECT"; + String canonical = inboundId + "|" + request.measurementId() + "|" + + request.expectedVersion() + "|" + normalize(request.reason()); + InboundIdempotencyService.Context context = idempotency.begin( + tenantId, operatorId, inboundId, operation, idempotencyKey, canonical); + if (context.replayed()) { + return; + } + + InboundOrderEntity order = requireOrder(tenantId, inboundId); + requirePendingReview(order); + InboundMeasurementEntity measurement = repository.findMeasurement( + tenantId, inboundId, request.measurementId()); + if (measurement == null) { + throw new BizException(ResultCode.NOT_FOUND, "待审核测量批次不存在"); + } + if (!"SUBMITTED".equals(measurement.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "测量批次不是待审核状态"); + } + + if (repository.transitionReview(tenantId, inboundId, operatorId, + request.expectedVersion(), approved) != 1) { + throw concurrentConflict(); + } + String result = approved ? "APPROVED" : "REJECTED"; + if (repository.transitionMeasurement(tenantId, inboundId, request.measurementId(), result) != 1) { + throw concurrentConflict(); + } + repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result, + request.reason(), false, null, false, operatorId, operatorName)); + repository.insertHistory(history(tenantId, inboundId, "MEASURE_PENDING", + approved ? "WAIT_PRICING" : "MEASURE_REJECTED", + approved ? "MEASURE_APPROVE" : "MEASURE_REJECT", request.reason(), operatorId, operatorName)); + idempotency.complete(context, inboundId); + } + + @Transactional + public void exceptionRelease(long tenantId, long operatorId, String operatorName, long inboundId, + String idempotencyKey, ExceptionReleaseRequest request) { + if (!StringUtils.hasText(request.reason())) { + throw new BizException(ResultCode.VALIDATION_FAILED, "异常放行必须填写原因"); + } + String canonical = inboundId + "|" + request.measurementId() + "|" + + request.expectedVersion() + "|" + normalize(request.reason()); + InboundIdempotencyService.Context context = idempotency.begin( + tenantId, operatorId, inboundId, "MEASURE_EXCEPTION_RELEASE", idempotencyKey, canonical); + if (context.replayed()) { + return; + } + if (!properties.isAllowRejectedMeasurement()) { + throw new BizException(ResultCode.CONFLICT, "测量驳回异常放行开关未开启"); + } + InboundOrderEntity order = requireOrder(tenantId, inboundId); + if (!"MEASURE_REJECTED".equals(order.getProcessStatus()) + || !"REJECTED".equals(order.getMeasureReviewStatus())) { + throw new BizException(ResultCode.CONFLICT, "只有测量审核驳回单可异常放行"); + } + InboundMeasurementEntity measurement = repository.findMeasurement( + tenantId, inboundId, request.measurementId()); + if (measurement == null || !"REJECTED".equals(measurement.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "被放行测量批次必须为已驳回状态"); + } + if (repository.releaseRejected(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) { + throw concurrentConflict(); + } + repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), "REJECTED", + null, true, request.reason(), true, operatorId, operatorName)); + repository.insertHistory(history(tenantId, inboundId, "MEASURE_REJECTED", "WAIT_PRICING", + "MEASURE_EXCEPTION_RELEASE", request.reason(), operatorId, operatorName)); + idempotency.complete(context, inboundId); + } + + public PageResult history( + long tenantId, long inboundId, long page, long size) { + requireOrder(tenantId, inboundId); + return repository.reviewHistory(tenantId, inboundId, page, size); + } + + 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 void requirePendingReview(InboundOrderEntity order) { + if (!"MEASURE_PENDING".equals(order.getProcessStatus()) + || !"PENDING".equals(order.getMeasureReviewStatus())) { + throw new BizException(ResultCode.CONFLICT, + "仅待测量审核单可审核,已审核请求请使用原 Idempotency-Key 重放"); + } + } + + private static BizException concurrentConflict() { + return new BizException(ResultCode.CONFLICT, "入库单已被并发更新,请刷新后重试"); + } + + private static String normalize(String value) { + return value == null ? "" : value.trim(); + } + + private static MeasureReviewRecordEntity reviewRecord( + long tenantId, long inboundId, long measurementId, String result, String reviewReason, + boolean exceptionRelease, String releaseReason, boolean configValue, + long operatorId, String operatorName) { + MeasureReviewRecordEntity record = new MeasureReviewRecordEntity(); + record.setTenantId(tenantId); + record.setInboundId(inboundId); + record.setMeasurementId(measurementId); + record.setReviewResult(result); + record.setReviewReason(normalize(reviewReason)); + record.setExceptionRelease(exceptionRelease); + record.setReleaseReason(releaseReason); + record.setAllowRejectedConfig(configValue); + record.setReviewerId(operatorId); + record.setReviewerName(operatorName); + record.setReviewedAt(LocalDateTime.now()); + return record; + } + + static InboundStatusHistoryEntity history(long tenantId, long inboundId, String from, String to, + String action, String reason, + long operatorId, String operatorName) { + InboundStatusHistoryEntity history = new InboundStatusHistoryEntity(); + history.setTenantId(tenantId); + history.setInboundId(inboundId); + history.setFromStatus(from); + history.setToStatus(to); + history.setAction(action); + history.setReason(reason); + history.setOperatorId(operatorId); + history.setOperatorName(operatorName); + history.setOccurredAt(LocalDateTime.now()); + return history; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java new file mode 100644 index 000000000..6e14b3230 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java @@ -0,0 +1,27 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.dto.InboundMeasurementRequests; +import com.baiyue.firstleg.dto.InboundMeasurementResponses; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class MeasurementSubmissionService { + private final InboundMeasurementService measurements; + private final MeasurementLocationOccupationService locations; + + public MeasurementSubmissionService(InboundMeasurementService measurements, + MeasurementLocationOccupationService locations) { + this.measurements = measurements; + this.locations = locations; + } + + @Transactional + public InboundMeasurementResponses.MeasurementSubmitted submit( + long tenantId, long inboundId, long operatorId, + InboundMeasurementRequests.SubmitRequest request) { + var result = measurements.submit(tenantId, inboundId, operatorId, request); + locations.occupy(tenantId, inboundId, operatorId, result.measurementId(), result.version()); + return result; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java new file mode 100644 index 000000000..c5211077e --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java @@ -0,0 +1,31 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.MerchantInboundBillingResponse; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.repository.InboundOrderRepository; +import org.springframework.stereotype.Service; + +@Service +public class MerchantInboundBillingService { + private final InboundOrderRepository orders; + private final FbaPricingService pricing; + + public MerchantInboundBillingService(InboundOrderRepository orders, FbaPricingService pricing) { + this.orders = orders; + this.pricing = pricing; + } + + public MerchantInboundBillingResponse find(long tenantId, long orderId) { + InboundOrderEntity inbound = orders.findByOrderId(tenantId, orderId); + if (inbound == null) { + throw new BizException(ResultCode.NOT_FOUND, "订单尚未生成入库单"); + } + if (!"INBOUNDED".equals(inbound.getProcessStatus()) || !"SENT".equals(inbound.getFeeStatus())) { + throw new BizException(ResultCode.NOT_FOUND, "入库费用尚未由客服审核并发布"); + } + return new MerchantInboundBillingResponse(orders.detail(tenantId, inbound), + pricing.latest(tenantId, inbound.getId())); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ThirdPartyCallLogService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ThirdPartyCallLogService.java new file mode 100644 index 000000000..34ba3aebc --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ThirdPartyCallLogService.java @@ -0,0 +1,91 @@ +package com.baiyue.firstleg.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.firstleg.config.MeasurementOpenApiProperties; +import com.baiyue.firstleg.entity.ThirdPartyCallLogEntity; +import com.baiyue.firstleg.mapper.ThirdPartyCallLogMapper; +import com.baiyue.firstleg.security.MeasurementRequestAuthenticator; +import com.baiyue.firstleg.security.MeasurementRequestHeaders; +import org.springframework.stereotype.Service; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** 将每次第三方 API 调用独立留档,业务事务回滚也不会丢失失败记录。 */ +@Service +public class ThirdPartyCallLogService { + private final ThirdPartyCallLogMapper mapper; + private final MeasurementOpenApiProperties properties; + + public ThirdPartyCallLogService(ThirdPartyCallLogMapper mapper, + MeasurementOpenApiProperties properties) { + this.mapper = mapper; + this.properties = properties; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public long start(String method, String path, MeasurementRequestHeaders headers, String rawBody) { + ThirdPartyCallLogEntity log = new ThirdPartyCallLogEntity(); + log.setId(IdWorker.getId()); + MeasurementOpenApiProperties.AppCredential app = properties.getApps().get(headers.appKey()); + log.setTenantId(app == null || app.getTenantId() == null ? 0L : app.getTenantId()); + log.setAppKey(limit(headers.appKey(), 128)); + log.setSignatureSubject(limit(headers.appKey(), 128)); + log.setRequestMethod(method); + log.setRequestPath(limit(path, 255)); + log.setRequestTimestamp(limit(headers.timestamp(), 32)); + log.setNonce(limit(headers.nonce(), 128)); + log.setIdempotencyKey(limit(headers.idempotencyKey(), 128)); + log.setSignatureValue(limit(headers.signature(), 128)); + log.setRequestHash(MeasurementRequestAuthenticator.sha256Hex(rawBody)); + log.setRawPayload(rawBody == null ? "" : rawBody); + log.setProcessingStatus("PROCESSING"); + log.setRequestedAt(LocalDateTime.now()); + mapper.insert(log); + return log.getId(); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void complete(long id, String status, String code, String message, String resultJson) { + ThirdPartyCallLogEntity update = new ThirdPartyCallLogEntity(); + update.setId(id); + update.setProcessingStatus(status); + update.setResultCode(limit(code, 32)); + update.setResultMessage(limit(message, 1000)); + update.setResultJson(resultJson); + update.setProcessedAt(LocalDateTime.now()); + mapper.updateById(update); + } + + /** 签名通过后原子认领 Idempotency-Key;非法签名不会污染幂等空间。 */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean claimIdempotency(long logId) { + ThirdPartyCallLogEntity update = new ThirdPartyCallLogEntity(); + update.setId(logId); + update.setIdempotencyAnchor(1); + try { + mapper.updateById(update); + return true; + } catch (DuplicateKeyException exception) { + return false; + } + } + + public ThirdPartyCallLogEntity findIdempotencyOwner(long tenantId, String appKey, String idempotencyKey) { + return mapper.selectOne(new LambdaQueryWrapper() + .eq(ThirdPartyCallLogEntity::getTenantId, tenantId) + .eq(ThirdPartyCallLogEntity::getAppKey, appKey) + .eq(ThirdPartyCallLogEntity::getIdempotencyKey, idempotencyKey) + .eq(ThirdPartyCallLogEntity::getIdempotencyAnchor, 1)); + } + + private String limit(String value, int max) { + if (value == null || value.length() <= max) { + return value; + } + return value.substring(0, max); + } +} diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index 8711b2e51..e42c5c5a4 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -23,6 +23,11 @@ spring: baiyue: security: jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} +firstleg: + inbound: + allow-rejected-measurement: false + default-pallet-capacity-cbm: ${DEFAULT_PALLET_CAPACITY_CBM:1.200000} + reservation-ttl: ${PALLET_RESERVATION_TTL:30m} management.endpoints.web.exposure.include: health,info,prometheus logging: diff --git a/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql b/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql new file mode 100644 index 000000000..73fa27b54 --- /dev/null +++ b/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql @@ -0,0 +1,52 @@ +SET NAMES utf8mb4; +USE baiyue_firstleg; + +-- 二期 FBA 计费增量迁移。通过 information_schema 判断,允许重复部署。 +DROP PROCEDURE IF EXISTS phase2_fba_add_column; +DELIMITER $$ +CREATE PROCEDURE phase2_fba_add_column(IN p_table VARCHAR(64), IN p_column VARCHAR(64), IN p_definition TEXT) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_table AND COLUMN_NAME = p_column + ) THEN + SET @sql = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_definition); + PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; + +CALL phase2_fba_add_column('fl_inbound_order', 'carrier_id', "BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID' AFTER channel_name"); +CALL phase2_fba_add_column('fl_inbound_order', 'carrier_name', "VARCHAR(128) NULL COMMENT '计费船司名称快照' AFTER carrier_id"); +CALL phase2_fba_add_column('fl_carrier_fba_rate', 'business_type', "VARCHAR(32) NOT NULL DEFAULT 'FBA' COMMENT '业务或计费类型' AFTER origin_warehouse_name"); +CALL phase2_fba_add_column('fl_customer_charge_rule', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted"); +CALL phase2_fba_add_column('fl_carrier_fba_rate', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted"); +CALL phase2_fba_add_column('fl_inbound_fee', 'rule_code', "VARCHAR(50) NOT NULL DEFAULT '' COMMENT '客户规则编码快照' AFTER carrier_rate_version"); +CALL phase2_fba_add_column('fl_inbound_fee', 'customer_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '客户ID快照' AFTER rule_code"); +CALL phase2_fba_add_column('fl_inbound_fee', 'customer_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '客户名称快照' AFTER customer_id"); +CALL phase2_fba_add_column('fl_inbound_fee', 'business_type', "VARCHAR(32) NOT NULL DEFAULT '' COMMENT '业务类型快照' AFTER customer_name"); +CALL phase2_fba_add_column('fl_inbound_fee', 'tax_mode', "VARCHAR(24) NULL COMMENT '包税模式快照' AFTER business_type"); +CALL phase2_fba_add_column('fl_inbound_fee', 'rounding_scale', "TINYINT UNSIGNED NOT NULL DEFAULT 3 COMMENT '计费量保留位数快照' AFTER tax_mode"); +CALL phase2_fba_add_column('fl_inbound_fee', 'rounding_mode', "VARCHAR(24) NOT NULL DEFAULT 'CEILING' COMMENT '舍入方式快照' AFTER rounding_scale"); +CALL phase2_fba_add_column('fl_inbound_fee', 'rule_effective_from', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '客户规则生效开始快照' AFTER rounding_mode"); +CALL phase2_fba_add_column('fl_inbound_fee', 'rule_effective_to', "DATETIME(3) NULL COMMENT '客户规则生效结束快照' AFTER rule_effective_from"); +CALL phase2_fba_add_column('fl_inbound_fee', 'rate_code', "VARCHAR(50) NOT NULL DEFAULT '' COMMENT '船司价格编码快照' AFTER rule_effective_to"); +CALL phase2_fba_add_column('fl_inbound_fee', 'origin_warehouse_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '起运仓ID快照' AFTER rate_code"); +CALL phase2_fba_add_column('fl_inbound_fee', 'origin_warehouse_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '起运仓名称快照' AFTER origin_warehouse_id"); +CALL phase2_fba_add_column('fl_inbound_fee', 'channel_code', "VARCHAR(64) NOT NULL DEFAULT '' COMMENT '渠道编码快照' AFTER origin_warehouse_name"); +CALL phase2_fba_add_column('fl_inbound_fee', 'carrier_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '船司ID快照' AFTER channel_code"); +CALL phase2_fba_add_column('fl_inbound_fee', 'carrier_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '船司名称快照' AFTER carrier_id"); +CALL phase2_fba_add_column('fl_inbound_fee', 'destination_code', "VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'FBA仓编码快照' AFTER carrier_name"); +CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_from', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '价格生效开始快照' AFTER destination_code"); +CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_to', "DATETIME(3) NULL COMMENT '价格生效结束快照' AFTER rate_effective_from"); +CALL phase2_fba_add_column('fl_inbound_idempotency', 'result_resource_id', "BIGINT UNSIGNED NULL COMMENT '成功结果资源ID' AFTER result_json"); + +DROP PROCEDURE IF EXISTS phase2_fba_add_column; + +-- 旧索引可能缺少业务类型、船司和结束时间;用新索引名并存,避免破坏线上依赖。 +SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_carrier_fba_rate' AND INDEX_NAME = 'idx_fba_rate_unique_match'); +SET @sql = IF(@idx_exists = 0, + 'ALTER TABLE fl_carrier_fba_rate ADD INDEX idx_fba_rate_unique_match (tenant_id, origin_warehouse_id, business_type, channel_code, carrier_id, destination_code, charge_unit, status, effective_from, effective_to)', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql b/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql new file mode 100644 index 000000000..db74556b3 --- /dev/null +++ b/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql @@ -0,0 +1,90 @@ +-- 二期入库测量与 OCR 维护增量(只增不删,可重复执行)。 +-- 执行顺序:phase2-inbound-schema.sql 后执行本文件;已有二期库只需执行本文件。 +SET NAMES utf8mb4; +USE baiyue_firstleg; + +DROP PROCEDURE IF EXISTS phase2_measure_add_column; +DELIMITER $$ +CREATE PROCEDURE phase2_measure_add_column( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_column_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND column_name = p_column_name + ) THEN + SET @phase2_measure_sql = CONCAT( + 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), + '` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), '` ', p_column_definition + ); + PREPARE phase2_measure_statement FROM @phase2_measure_sql; + EXECUTE phase2_measure_statement; + DEALLOCATE PREPARE phase2_measure_statement; + END IF; +END$$ +DELIMITER ; + +CALL phase2_measure_add_column('fl_inbound_measurement', 'original_document_no', + 'VARCHAR(64) NULL COMMENT ''原单据号码快照'' AFTER raw_payload_json'); +CALL phase2_measure_add_column('fl_inbound_measurement', 'original_gross_weight_kg', + 'DECIMAL(18,3) NULL COMMENT ''原单据毛重kg'' AFTER original_document_no'); +CALL phase2_measure_add_column('fl_inbound_measurement', 'original_volume_cbm', + 'DECIMAL(18,4) NULL COMMENT ''原单据体积或材积m3'' AFTER original_gross_weight_kg'); +CALL phase2_measure_add_column('fl_inbound_measurement', 'actual_goods_name', + 'VARCHAR(255) NULL COMMENT ''实际品名'' AFTER original_volume_cbm'); +CALL phase2_measure_add_column('fl_inbound_measurement', 'volume_weight_kg', + 'DECIMAL(18,3) NULL COMMENT ''本批次总材积重kg'' AFTER total_volume_cbm'); +CALL phase2_measure_add_column('fl_inbound_measurement', 'version', + 'INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本号'' AFTER updated_at'); + +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'box_specification', + 'VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''箱规标识'' AFTER item_no'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'girth_cm', + 'DECIMAL(12,3) NOT NULL DEFAULT 0 COMMENT ''周长cm,规则为长+2*(宽+高)'' AFTER height_cm'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'unit_weight_kg', + 'DECIMAL(18,3) NULL COMMENT ''单箱重量kg'' AFTER girth_cm'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'unit_volume_cbm', + 'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''单箱业务体积m3'' AFTER unit_weight_kg'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'total_weight_kg', + 'DECIMAL(18,3) NULL COMMENT ''本箱规总重量kg'' AFTER volume_cbm'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'total_volume_cbm', + 'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''本箱规总体积m3'' AFTER total_weight_kg'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'volume_weight_kg', + 'DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''本箱规材积重kg'' AFTER total_volume_cbm'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'over_limit_warning', + 'VARCHAR(500) NULL COMMENT ''超线提醒快照'' AFTER source_image_file_id'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_length_cm', + 'DECIMAL(12,3) NULL COMMENT ''外箱规长度cm'' AFTER over_limit_warning'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_width_cm', + 'DECIMAL(12,3) NULL COMMENT ''外箱规宽度cm'' AFTER outer_length_cm'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_height_cm', + 'DECIMAL(12,3) NULL COMMENT ''外箱规高度cm'' AFTER outer_width_cm'); + +DROP PROCEDURE IF EXISTS phase2_measure_add_column; + +CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( + id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + weight_result_id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID', + revision_no INT UNSIGNED NOT NULL COMMENT '结果版本号,与乐观锁版本一致', + action VARCHAR(24) NOT NULL COMMENT '动作:CORRECT或CONFIRM', + before_weight_value DECIMAL(18,3) NULL COMMENT '变更前原单位重量', + before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位', + after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量', + after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位', + final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg', + reason VARCHAR(500) NULL COMMENT '人工修正原因', + 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), + UNIQUE KEY uk_weight_revision_no (tenant_id, weight_result_id, revision_no), + KEY idx_weight_revision_time (tenant_id, inbound_id, operated_at), + CONSTRAINT fk_weight_revision_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_weight_revision_result FOREIGN KEY (weight_result_id) REFERENCES fl_inbound_weight_result (id) +) ENGINE=InnoDB COMMENT='OCR重量人工修正和确认的不可变版本历史'; diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql new file mode 100644 index 000000000..f6940e9a3 --- /dev/null +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -0,0 +1,533 @@ +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 '渠道名称快照', + carrier_id BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID', + carrier_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 '第三方原始报文', + original_document_no VARCHAR(64) NULL COMMENT '原单据号码快照', + original_gross_weight_kg DECIMAL(18,3) NULL COMMENT '原单据毛重kg', + original_volume_cbm DECIMAL(18,4) NULL COMMENT '原单据体积或材积m3', + actual_goods_name VARCHAR(255) 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', + volume_weight_kg DECIMAL(18,3) NULL COMMENT '本批次总材积重kg', + 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 '更新时间', + version INT NOT NULL DEFAULT 0 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 '批次内明细号', + box_specification VARCHAR(64) 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', + girth_cm DECIMAL(12,3) NOT NULL COMMENT '周长cm,规则为长+2*(宽+高)', + unit_weight_kg DECIMAL(18,3) NULL COMMENT '单箱重量kg,第三方仅回传尺寸时可为空', + unit_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '单箱业务体积m3', + volume_raw_cbm DECIMAL(18,6) NOT NULL COMMENT '原始体积m3', + volume_cbm DECIMAL(18,4) NOT NULL COMMENT '业务体积m3', + total_weight_kg DECIMAL(18,3) NULL COMMENT '本箱规总重量kg', + total_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本箱规总体积m3', + volume_weight_kg DECIMAL(18,3) NOT NULL COMMENT '本箱规材积重kg', + calculation_rule_version VARCHAR(32) NOT NULL COMMENT '体积计算和舍入规则版本', + source_image_file_id BIGINT UNSIGNED NULL COMMENT '测量设备图片文件ID', + over_limit_warning VARCHAR(500) NULL COMMENT '超线提醒快照', + outer_length_cm DECIMAL(12,3) NULL COMMENT '外箱规长度cm', + outer_width_cm DECIMAL(12,3) NULL COMMENT '外箱规宽度cm', + outer_height_cm DECIMAL(12,3) NULL COMMENT '外箱规高度cm', + remark VARCHAR(500) NULL COMMENT '测量明细备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + 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_inbound_weight_revision ( + id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + weight_result_id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID', + revision_no INT UNSIGNED NOT NULL COMMENT '结果版本号,与乐观锁版本一致', + action VARCHAR(24) NOT NULL COMMENT '动作:CORRECT或CONFIRM', + before_weight_value DECIMAL(18,3) NULL COMMENT '变更前原单位重量', + before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位', + after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量', + after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位', + final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg', + reason VARCHAR(500) NULL COMMENT '人工修正原因', + 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), + UNIQUE KEY uk_weight_revision_no (tenant_id, weight_result_id, revision_no), + KEY idx_weight_revision_time (tenant_id, inbound_id, operated_at), + CONSTRAINT fk_weight_revision_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_weight_revision_result FOREIGN KEY (weight_result_id) REFERENCES fl_inbound_weight_result (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), + KEY idx_review_exception_release (tenant_id, inbound_id, exception_release, 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 '逻辑删除标记', + version INT 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 '起运仓名称快照', + business_type VARCHAR(32) 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 '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_fba_rate_version (tenant_id, rate_code, rate_version), + KEY idx_fba_rate_match (tenant_id, origin_warehouse_id, business_type, channel_code, carrier_id, destination_code, charge_unit, status, effective_from, effective_to) +) 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 '船司价格版本快照', + rule_code VARCHAR(50) 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 '业务类型快照', + tax_mode VARCHAR(24) NULL COMMENT '包税模式快照', + rounding_scale TINYINT UNSIGNED NOT NULL COMMENT '计费量保留位数快照', + rounding_mode VARCHAR(24) NOT NULL COMMENT '舍入方式快照', + rule_effective_from DATETIME(3) NOT NULL COMMENT '客户规则生效开始快照', + rule_effective_to DATETIME(3) NULL COMMENT '客户规则生效结束快照', + rate_code VARCHAR(50) NOT NULL COMMENT '船司价格编码快照', + origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓ID快照', + origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照', + channel_code VARCHAR(64) NOT NULL COMMENT '渠道编码快照', + carrier_id BIGINT UNSIGNED NOT NULL COMMENT '船司ID快照', + carrier_name VARCHAR(128) NOT NULL COMMENT '船司名称快照', + destination_code VARCHAR(32) NOT NULL COMMENT 'FBA仓编码快照', + rate_effective_from DATETIME(3) NOT NULL COMMENT '价格生效开始快照', + rate_effective_to DATETIME(3) NULL COMMENT '价格生效结束快照', + charge_mode VARCHAR(16) NOT NULL COMMENT '计费口径:KG或CBM', + conversion_factor DECIMAL(18,6) NOT NULL COMMENT '使用的换算系数快照', + 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 '成功结果摘要', + result_resource_id BIGINT UNSIGNED NULL COMMENT '成功结果资源ID', + 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='第三方测量、审核、费用发送和入库写操作幂等记录'; diff --git a/firstleg/src/main/resources/db/phase2-inbound-workflow-migration.sql b/firstleg/src/main/resources/db/phase2-inbound-workflow-migration.sql new file mode 100644 index 000000000..bdb6b4562 --- /dev/null +++ b/firstleg/src/main/resources/db/phase2-inbound-workflow-migration.sql @@ -0,0 +1,13 @@ +-- 业务块4增量迁移:既有二期库补齐异常放行关口查询索引。 +-- 可重复执行;新库仍先执行 phase2-inbound-schema.sql。 +USE baiyue_firstleg; +SET NAMES utf8mb4; + +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_measure_review_record' + AND index_name = 'idx_review_exception_release'), + 'SELECT 1', + 'ALTER TABLE fl_measure_review_record ADD INDEX idx_review_exception_release (tenant_id, inbound_id, exception_release, reviewed_at)' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/firstleg/src/main/resources/db/phase2-measurement-openapi.sql b/firstleg/src/main/resources/db/phase2-measurement-openapi.sql new file mode 100644 index 000000000..418902c66 --- /dev/null +++ b/firstleg/src/main/resources/db/phase2-measurement-openapi.sql @@ -0,0 +1,30 @@ +SET NAMES utf8mb4; +USE baiyue_firstleg; + +-- 既有二期数据库增量:先确保 phase2-inbound-schema.sql 中的测量和幂等表已存在。 +CREATE TABLE IF NOT EXISTS fl_third_party_call_log ( + id BIGINT UNSIGNED NOT NULL COMMENT '第三方调用日志ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '识别出的租户ID,未知应用为0', + app_key VARCHAR(128) NULL COMMENT '第三方应用标识', + signature_subject VARCHAR(128) NULL COMMENT '签名主体快照', + request_method VARCHAR(8) NOT NULL COMMENT 'HTTP方法', + request_path VARCHAR(255) NOT NULL COMMENT '签名使用的规范路径', + request_timestamp VARCHAR(32) NULL COMMENT '请求头Unix毫秒时间戳原值', + nonce VARCHAR(128) NULL COMMENT '请求Nonce', + idempotency_key VARCHAR(128) NULL COMMENT 'HTTP幂等键,查询接口为空', + idempotency_anchor TINYINT UNSIGNED NULL COMMENT '签名通过后首个请求为1,重复调用为空', + signature_value VARCHAR(128) NULL COMMENT '第三方签名原值', + request_hash CHAR(64) NOT NULL COMMENT '原始请求体SHA-256小写十六进制', + raw_payload LONGTEXT NOT NULL COMMENT '未经重新序列化的UTF-8原始请求体', + processing_status VARCHAR(16) NOT NULL COMMENT 'PROCESSING、ACCEPTED或REJECTED', + result_code VARCHAR(32) NULL COMMENT '统一响应业务码', + result_message VARCHAR(1000) NULL COMMENT '处理结果或失败原因', + result_json JSON NULL COMMENT '首次成功结果快照', + requested_at DATETIME(3) NOT NULL COMMENT '服务端接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + PRIMARY KEY (id), + KEY idx_third_call_app_time (tenant_id, app_key, requested_at), + KEY idx_third_call_idempotency (tenant_id, app_key, idempotency_key), + KEY idx_third_call_nonce (tenant_id, app_key, nonce), + UNIQUE KEY uk_third_call_idempotency_owner (tenant_id, app_key, idempotency_key, idempotency_anchor) +) ENGINE=InnoDB COMMENT='第三方测量开放接口原始请求、签名主体和处理结果留档'; diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml new file mode 100644 index 000000000..6ff65af5c --- /dev/null +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -0,0 +1,138 @@ +openapi: 3.0.3 +info: + title: 柏悦二期 FBA 计费 API + version: 2.0.0 + description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功。 +security: + - bearerAuth: [] +paths: + /api/platform/v1/fba-pricing/customer-rules: + get: + summary: 查询客户计费规则版本 + description: 需要 firstleg:fba-pricing:rule:view 权限 + parameters: [{$ref: '#/components/parameters/TenantId'}] + responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} + /api/platform/v1/fba-pricing/customer-rules/versions: + post: + summary: 新建客户计费规则版本 + description: 版本内容不可原地修改;有效期使用 [effectiveFrom,effectiveTo) 半开区间。 + parameters: [{$ref: '#/components/parameters/TenantId'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CustomerRuleVersionRequest'}}}} + responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} + /api/platform/v1/fba-pricing/customer-rules/{id}: + get: + summary: 查询客户计费规则版本详情 + parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/Id'}] + responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} + delete: + summary: 停用客户计费规则版本 + parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/Id'}] + responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} + /api/platform/v1/fba-pricing/carrier-rates: + get: + summary: 查询船司 FBA 价格版本 + description: 需要 firstleg:fba-pricing:rate:view 权限 + parameters: [{$ref: '#/components/parameters/TenantId'}] + responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} + /api/platform/v1/fba-pricing/carrier-rates/versions: + post: + summary: 新建船司 FBA 价格版本 + parameters: [{$ref: '#/components/parameters/TenantId'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CarrierRateVersionRequest'}}}} + responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} + /api/platform/v1/fba-pricing/carrier-rates/{id}: + get: + summary: 查询船司 FBA 价格版本详情 + parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/Id'}] + responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} + delete: + summary: 停用船司 FBA 价格版本 + parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/Id'}] + responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} + /api/platform/v1/inbounds/{inboundId}/fees/calculate: + post: + summary: 按审核通过测量快照试算 FBA 费用 + description: 精确匹配唯一客户规则和唯一船司价格;需要 firstleg:fba-pricing:calculate 权限。 + parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/CalculateRequest'}}}} + responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} + /api/platform/v1/inbounds/{inboundId}/fees/confirm: + post: + summary: 确认最新费用快照 + parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} + /api/platform/v1/inbounds/{inboundId}/fees/send: + post: + summary: 手工恢复发送费用快照 + description: 仅作为异常恢复接口;正常业务由客服测量审核通过后自动计费、发布并完成入库。 + parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} + /api/platform/v1/inbounds/{inboundId}/fees/latest: + get: + summary: 查询最新费用快照 + parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}] + responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} + /api/platform/v1/inbounds/{inboundId}/fees/published: + put: + summary: 调整已发布费用 + description: 需要 firstleg:fba-pricing:adjust 权限;生成新 SENT 版本并将旧版本标记 SUPERSEDED,不回退 INBOUNDED 状态。 + parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/AdjustFeeRequest'} + responses: {'200': {description: 成功或业务失败}, '403': {description: 无调整权限}} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + parameters: + TenantId: {name: tenantId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} + Id: {name: id, in: path, required: true, schema: {type: integer, format: int64}} + InboundId: {name: inboundId, in: path, required: true, schema: {type: integer, format: int64}} + IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} + schemas: + AdjustFeeRequest: + type: object + required: [subtotalAmount, additionalAmount, reason] + properties: + subtotalAmount: {type: number, minimum: 0, multipleOf: 0.01} + additionalAmount: {type: number, minimum: 0, multipleOf: 0.01} + reason: {type: string, minLength: 1, maxLength: 500} + CalculateRequest: + type: object + properties: {pricingAt: {type: string, format: date-time, description: 缺省为服务端当前时间}} + CustomerRuleVersionRequest: + type: object + required: [ruleCode, customerId, customerName, businessType, chargeMode, taxMode, conversionFactor, roundingScale, roundingMode, currency, effectiveFrom] + properties: + ruleCode: {type: string, maxLength: 50} + customerId: {type: integer, format: int64} + customerName: {type: string, maxLength: 128} + businessType: {type: string, maxLength: 32} + chargeMode: {type: string, enum: [KG, CBM]} + taxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]} + conversionFactor: {type: number, exclusiveMinimum: 0, example: 167} + roundingScale: {type: integer, minimum: 0, maximum: 4} + roundingMode: {type: string, enum: [UP, DOWN, CEILING, FLOOR, HALF_UP, HALF_DOWN, HALF_EVEN]} + currency: {type: string, pattern: '^[A-Z]{3}$'} + effectiveFrom: {type: string, format: date-time} + effectiveTo: {type: string, format: date-time, nullable: true} + CarrierRateVersionRequest: + type: object + required: [rateCode, originWarehouseId, originWarehouseName, businessType, channelCode, carrierId, carrierName, destinationCode, chargeUnit, unitPrice, currency, additionalFee, effectiveFrom] + properties: + rateCode: {type: string, maxLength: 50} + originWarehouseId: {type: integer, format: int64} + originWarehouseName: {type: string} + businessType: {type: string} + channelCode: {type: string} + carrierId: {type: integer, format: int64} + carrierName: {type: string} + destinationCode: {type: string} + chargeUnit: {type: string, enum: [KG, CBM]} + unitPrice: {type: number, exclusiveMinimum: 0} + currency: {type: string, pattern: '^[A-Z]{3}$'} + additionalFee: {type: number, minimum: 0} + effectiveFrom: {type: string, format: date-time} + effectiveTo: {type: string, format: date-time, nullable: true} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 5710c91a9..e4dde5f5e 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -67,13 +67,160 @@ paths: responses: '200': {description: 成功或业务失败,按响应体 code 判断} '403': {description: 无分配权限} + /openapi/v1/inbound-measurements: + post: + summary: 提交第三方长宽高测量结果 + description: >- + 只写 fl_third_party_call_log、fl_inbound_measurement、fl_inbound_measurement_item + 和 HTTP 幂等结果,不审核、不计费、不入库,也不改变入库单状态。 + 签名串依次为 METHOD、原始 PATH、X-Timestamp、X-Nonce、Idempotency-Key、 + 原始 UTF-8 body 的 SHA-256 小写十六进制,六部分使用 LF(\n)连接且末尾不追加换行。 + 三层语义:Nonce 防短时重放;Idempotency-Key 标识一次 HTTP 写操作; + appKey + eventNo 标识第三方业务事件。 + security: + - measurementAppKey: [] + measurementTimestamp: [] + measurementNonce: [] + measurementSignature: [] + parameters: + - {$ref: '#/components/parameters/XTimestamp'} + - {$ref: '#/components/parameters/XNonce'} + - {$ref: '#/components/parameters/IdempotencyKey'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/InboundMeasurementCallbackRequest'} + responses: + '200': {description: 成功或业务失败,按响应体字符串 code 判断;非法签名返回 A0401} + '413': {description: 请求体超过网关限制} + '415': {description: Content-Type 不支持} + /openapi/v1/inbound-measurements/events/{eventNo}: + get: + summary: 查询第三方测量事件状态 + description: GET 签名串的 Idempotency-Key 行为空字符串,body 摘要为空字节 SHA-256。 + security: + - measurementAppKey: [] + measurementTimestamp: [] + measurementNonce: [] + measurementSignature: [] + parameters: + - name: eventNo + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9._:-]{1,128}$'} + - {$ref: '#/components/parameters/XTimestamp'} + - {$ref: '#/components/parameters/XNonce'} + responses: + '200': {description: 成功或业务失败,按响应体字符串 code 判断} + /api/platform/inbounds: + get: + summary: 分页查询入库单 + description: 需要 platform:firstleg:inbound:list 权限;仅使用 V2 四个页签 + parameters: + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: tab, in: query, schema: {type: string, enum: [WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, INBOUNDED]}} + responses: + '200': {description: 成功或业务失败,按响应体 code 判断} + '403': {description: 无查询权限} + /api/platform/inbounds/{inboundId}: + get: + summary: 查询入库单详情 + description: 需要 platform:firstleg:inbound:view 权限 + parameters: + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': {description: 成功或业务失败,按响应体 code 判断} + '403': {description: 无查看权限} + /api/platform/v1/inbounds/{inboundId}/measurements/approve: + post: + summary: 审核通过测量 + description: 需要 firstleg:inbound:measurement-review 权限;客服审核通过后自动计费、发布费用给客户并完成入库,客户无需确认。 + parameters: + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: Idempotency-Key, in: header, required: true, schema: {type: string}} + responses: + '200': {description: 成功或业务失败} + /api/platform/v1/inbounds/{inboundId}/measurements/reject: + post: + summary: 驳回测量 + description: 需要 firstleg:inbound:measurement-review 权限;只退回测量数据修改,已确认托盘和库位占用保持不动。 + parameters: + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: Idempotency-Key, in: header, required: true, schema: {type: string}} + responses: + '200': {description: 成功或业务失败} + /api/platform/v1/inbounds/{inboundId}/measurements/submit: + post: + summary: 提交测量完成 + description: 测量提交后立即按总体积拆分普通托盘、分配并确认占用库位,再进入客服待审核;无普通空闲位时共享 A0。 + parameters: + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': {description: 成功时 processStatus=MEASURE_PENDING、measureReviewStatus=PENDING,且库位已 OCCUPIED} + '403': {description: 无测量提交权限} + /api/merchant/v1/first-leg/orders/{orderId}/inbound-billing: + get: + summary: 客户查询已完成入库结果和最新已发布费用 + description: 需要 merchant:order:view 权限;客户只读查看,不需要确认费用,未完成客服审核发布时不可见。 + parameters: + - {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': {description: 成功或业务失败,按响应体 code 判断} + '403': {description: 非本租户或无订单查看权限} + /api/platform/v1/inbounds/{inboundId}/measurements/exception-release: + post: + summary: 测量异常放行 + description: 需要 firstleg:inbound:exception-release 权限 + parameters: + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: Idempotency-Key, in: header, required: true, schema: {type: string}} + responses: + '200': {description: 成功或业务失败} components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT + measurementAppKey: + type: apiKey + in: header + name: X-App-Key + description: 分配给测量服务商的应用标识 + measurementTimestamp: + type: apiKey + in: header + name: X-Timestamp + description: Unix 毫秒时间戳 + measurementNonce: + type: apiKey + in: header + name: X-Nonce + description: 每次请求唯一的随机串 + measurementSignature: + type: apiKey + in: header + name: X-Signature + description: HMAC-SHA256 小写十六进制签名 parameters: + XTimestamp: + name: X-Timestamp + in: header + required: true + schema: {type: string, pattern: '^[0-9]{13}$', example: '1785551400000'} + XNonce: + name: X-Nonce + in: header + required: true + schema: {type: string, minLength: 1, maxLength: 128} + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: {type: string, minLength: 1, maxLength: 128} TenantId: name: tenantId in: query @@ -93,6 +240,39 @@ components: in: query schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20} schemas: + InboundMeasurementCallbackRequest: + type: object + required: [eventNo, warehouseCode, inboundCode, deviceCode, measuredAt, items] + properties: + eventNo: {type: string, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$', example: M202608010001} + warehouseCode: {type: string, maxLength: 50, example: CN-YW-01} + inboundCode: {type: string, maxLength: 40, example: RK202608010001} + deviceCode: {type: string, maxLength: 64, example: DEVICE-01} + measuredAt: {type: string, format: date-time, example: '2026-08-01T10:30:00+08:00'} + items: + type: array + minItems: 1 + maxItems: 500 + items: {$ref: '#/components/schemas/InboundMeasurementItem'} + InboundMeasurementItem: + type: object + required: [itemNo, boxSpecification, lengthCm, widthCm, heightCm, quantity] + properties: + itemNo: {type: string, maxLength: 32, example: '001'} + boxSpecification: {type: string, maxLength: 64, example: '61.5x42x42cm', description: 入库单绑定的箱规明细} + lengthCm: {type: number, exclusiveMinimum: true, minimum: 0, example: 50.20} + widthCm: {type: number, exclusiveMinimum: true, minimum: 0, example: 40.10} + heightCm: {type: number, exclusiveMinimum: true, minimum: 0, example: 30.00} + quantity: {type: integer, minimum: 1, maximum: 1000000, example: 5} + sourceImageUrl: {type: string, maxLength: 2048, nullable: true, description: 仅随原始报文留档,不直接写文件中心} + InboundMeasurementEventResponse: + type: object + properties: + eventNo: {type: string} + inboundCode: {type: string} + accepted: {type: boolean} + status: {type: string, enum: [RECEIVED]} + receivedAt: {type: string, format: date-time} AssignRequest: type: object required: [assigneeId, assigneeName] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/config/InboundPropertiesTest.java b/firstleg/src/test/java/com/baiyue/firstleg/config/InboundPropertiesTest.java new file mode 100644 index 000000000..08ad70b31 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/config/InboundPropertiesTest.java @@ -0,0 +1,15 @@ +package com.baiyue.firstleg.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("入库动态配置") +class InboundPropertiesTest { + @Test + @DisplayName("Nacos缺失时异常放行默认关闭") + void shouldDisableRejectedMeasurementReleaseByDefault() { + assertThat(new InboundProperties().isAllowRejectedMeasurement()).isFalse(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java new file mode 100644 index 000000000..535e927a2 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java @@ -0,0 +1,32 @@ +package com.baiyue.firstleg.controller; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("FBA计费OpenAPI契约") +class FbaPricingContractTest { + @Test + @DisplayName("契约声明自动发布、已发布费用调整和幂等头") + void documentsPricingLifecycle() throws Exception { + String openapi = new ClassPathResource("openapi/fba-pricing-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(openapi) + .contains("/api/platform/v1/fba-pricing/customer-rules/versions:") + .contains("/api/platform/v1/fba-pricing/carrier-rates/versions:") + .contains("/api/platform/v1/inbounds/{inboundId}/fees/calculate:") + .contains("/api/platform/v1/inbounds/{inboundId}/fees/confirm:") + .contains("/api/platform/v1/inbounds/{inboundId}/fees/send:") + .contains("/api/platform/v1/inbounds/{inboundId}/fees/published:") + .contains("Idempotency-Key") + .contains("客服测量审核通过后自动计费、发布并完成入库") + .contains("不回退 INBOUNDED 状态") + .doesNotContain("应收") + .doesNotContain("应付") + .doesNotContain("待客户确认"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java new file mode 100644 index 000000000..bc2674e2c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java @@ -0,0 +1,50 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.dto.ExceptionReleaseRequest; +import com.baiyue.firstleg.dto.MeasurementReviewRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("入库测量审核控制器契约") +class InboundMeasurementReviewContractTest { + @Test + @DisplayName("审核、放行和历史接口声明独立按钮权限") + void shouldDeclareButtonPermissions() throws Exception { + assertAuthority("approve", "hasAuthority('firstleg:inbound:measurement-review')", + AuthenticatedUser.class, long.class, long.class, String.class, MeasurementReviewRequest.class); + assertAuthority("reject", "hasAuthority('firstleg:inbound:measurement-review')", + AuthenticatedUser.class, long.class, long.class, String.class, MeasurementReviewRequest.class); + assertAuthority("exceptionRelease", "hasAuthority('firstleg:inbound:exception-release')", + AuthenticatedUser.class, long.class, long.class, String.class, ExceptionReleaseRequest.class); + assertAuthority("history", "hasAuthority('firstleg:inbound:measurement-review-history')", + AuthenticatedUser.class, long.class, long.class, long.class, long.class); + } + + @Test + @DisplayName("OpenAPI不包含待客户确认并声明幂等和字符串长ID") + void shouldDocumentReviewContract() throws Exception { + String openapi = new ClassPathResource("openapi/firstleg-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(openapi) + .contains("/api/platform/v1/inbounds/{inboundId}/measurements/approve:") + .contains("/api/platform/v1/inbounds/{inboundId}/measurements/reject:") + .contains("firstleg:inbound:exception-release") + .contains("Idempotency-Key") + .contains("name: inboundId") + .contains("schema: {type: string, pattern:") + .doesNotContain("待客户确认"); + } + + private void assertAuthority(String name, String expression, Class... types) throws Exception { + Method method = PlatformInboundMeasurementReviewController.class.getDeclaredMethod(name, types); + assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo(expression); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundOrderControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundOrderControllerContractTest.java new file mode 100644 index 000000000..3737d5487 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundOrderControllerContractTest.java @@ -0,0 +1,40 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.dto.InboundOrderQuery; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundOrderControllerContractTest { + @Test + void shouldDeclareReadOnlyPermissions() throws Exception { + assertAuthority("page", "hasAuthority('platform:firstleg:inbound:list')", + AuthenticatedUser.class, long.class, InboundOrderQuery.class); + assertAuthority("detail", "hasAuthority('platform:firstleg:inbound:view')", + AuthenticatedUser.class, long.class, long.class); + } + + @Test + void shouldDocumentOnlyV2TabsAndStringLongIds() throws Exception { + String openapi = new ClassPathResource("openapi/firstleg-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(openapi) + .contains("/api/platform/inbounds:") + .contains("/api/platform/inbounds/{inboundId}:") + .contains("[WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, INBOUNDED]") + .contains("platform:firstleg:inbound:list") + .doesNotContain("WAIT_CUSTOMER_CONFIRM"); + } + + private static void assertAuthority(String methodName, String value, Class... types) + throws NoSuchMethodException { + Method method = PlatformInboundOrderController.class.getDeclaredMethod(methodName, types); + assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo(value); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/MerchantInboundBillingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/MerchantInboundBillingControllerContractTest.java new file mode 100644 index 000000000..3aee456fb --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/MerchantInboundBillingControllerContractTest.java @@ -0,0 +1,35 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.service.MerchantInboundBillingService; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +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; + +class MerchantInboundBillingControllerContractTest { + private final MerchantInboundBillingController controller = + new MerchantInboundBillingController(mock(MerchantInboundBillingService.class)); + + @Test + void 客户费用查询复用订单查看权限且不提供确认接口() throws Exception { + var method = MerchantInboundBillingController.class.getDeclaredMethod( + "find", AuthenticatedUser.class, long.class); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('merchant:order:view')"); + assertThat(MerchantInboundBillingController.class.getDeclaredMethods()) + .noneMatch(candidate -> candidate.getName().toLowerCase().contains("confirm")); + } + + @Test + void 平台身份不能越权调用客户费用接口() { + var platform = new AuthenticatedUser(1L, 0L, 0L, 1L, + "admin", "PLATFORM", "WEB", Set.of()); + assertThatThrownBy(() -> controller.find(platform, 77L)).isInstanceOf(BizException.class); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementControllerContractTest.java new file mode 100644 index 000000000..e32ba60ea --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementControllerContractTest.java @@ -0,0 +1,38 @@ +package com.baiyue.firstleg.controller; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.web.bind.annotation.RequestBody; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("第三方测量开放接口契约") +class OpenApiInboundMeasurementControllerContractTest { + @Test + @DisplayName("回调以原始字符串接收报文,保证签名正文不被重新序列化") + void shouldReceiveRawRequestBody() throws Exception { + Method method = OpenApiInboundMeasurementController.class.getDeclaredMethod("receive", + String.class, String.class, String.class, String.class, String.class, String.class); + assertThat(method.getParameters()[5].getAnnotation(RequestBody.class)).isNotNull(); + assertThat(method.getParameters()[5].getType()).isEqualTo(String.class); + } + + @Test + @DisplayName("静态OpenAPI声明回调、状态查询、签名和三层幂等") + void shouldDocumentMeasurementOpenApiContract() throws Exception { + String openapi = new ClassPathResource("openapi/firstleg-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(openapi) + .contains("/openapi/v1/inbound-measurements:") + .contains("/openapi/v1/inbound-measurements/events/{eventNo}:") + .contains("X-Signature") + .contains("Idempotency-Key") + .contains("appKey + eventNo") + .contains("Nonce") + .contains("不改变入库单状态"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java new file mode 100644 index 000000000..78f4c0ada --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java @@ -0,0 +1,44 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.dto.InboundMeasurementRequests; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("平台入库测量接口权限契约") +class PlatformInboundMeasurementControllerContractTest { + @Test + void everyMeasurementOperationUsesDedicatedButtonPermission() throws Exception { + assertAuthority("detail", "hasAuthority('platform:firstleg:inbound:measure:view')", + AuthenticatedUser.class, long.class, long.class); + assertAuthority("saveDraft", "hasAuthority('platform:firstleg:inbound:measure:edit')", + AuthenticatedUser.class, long.class, long.class, + InboundMeasurementRequests.SaveDraftRequest.class); + assertAuthority("submit", "hasAuthority('platform:firstleg:inbound:measure:submit')", + AuthenticatedUser.class, long.class, long.class, + InboundMeasurementRequests.SubmitRequest.class); + assertAuthority("createWeightResult", "hasAuthority('platform:firstleg:inbound:weight:edit')", + AuthenticatedUser.class, long.class, long.class, + InboundMeasurementRequests.CreateWeightResultRequest.class); + assertAuthority("correctWeightResult", "hasAuthority('platform:firstleg:inbound:weight:edit')", + AuthenticatedUser.class, long.class, long.class, long.class, + InboundMeasurementRequests.CorrectWeightResultRequest.class); + assertAuthority("confirmWeightResult", "hasAuthority('platform:firstleg:inbound:weight:confirm')", + AuthenticatedUser.class, long.class, long.class, long.class, + InboundMeasurementRequests.ConfirmWeightResultRequest.class); + } + + private void assertAuthority(String methodName, String expected, Class... parameterTypes) + throws NoSuchMethodException { + Method method = PlatformInboundMeasurementController.class + .getDeclaredMethod(methodName, parameterTypes); + PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class); + assertThat(preAuthorize).isNotNull(); + assertThat(preAuthorize.value()).isEqualTo(expected); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java new file mode 100644 index 000000000..af833878b --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java @@ -0,0 +1,115 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.firstleg.dto.InboundOrderQuery; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; +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.junit.jupiter.api.Test; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.mockito.ArgumentCaptor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +class InboundOrderRepositoryTest { + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void shouldApplyTenantCompanyAndWarehouseFilters() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "test"), InboundOrderEntity.class); + InboundOrderMapper orders = mock(InboundOrderMapper.class); + when(orders.selectPage(any(Page.class), any(Wrapper.class))) + .thenReturn(new Page(1, 20)); + InboundOrderRepository repository = new InboundOrderRepository( + orders, + mock(InboundTransportSnapshotMapper.class), + mock(InboundPalletAllocationMapper.class), + mock(InboundFileMapper.class), + mock(InboundInternalNoteMapper.class), + mock(InboundStatusHistoryMapper.class)); + InboundOrderQuery query = new InboundOrderQuery( + null, null, null, null, null, null, null, null, + null, null, null, null, 20001L, 30001L, null, null, null, 1, 20); + + repository.page(10001L, query); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(orders).selectPage(any(Page.class), captor.capture()); + assertThat(captor.getValue().getCustomSqlSegment()) + .contains("tenant_id") + .contains("company_id") + .contains("warehouse_id") + .contains("ORDER BY created_at DESC,id DESC"); + LambdaQueryWrapper wrapper = + (LambdaQueryWrapper) captor.getValue(); + assertThat(wrapper.getParamNameValuePairs().values()) + .contains(10001L, 20001L, 30001L); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void shouldBatchAggregateLocationCodesInAllocationOrderIncludingA0() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "test"), InboundOrderEntity.class); + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "test"), + InboundPalletAllocationEntity.class); + InboundOrderMapper orders = mock(InboundOrderMapper.class); + InboundPalletAllocationMapper pallets = mock(InboundPalletAllocationMapper.class); + Page page = new Page<>(1, 20); + InboundOrderEntity first = new InboundOrderEntity(); + first.setId(1L); + InboundOrderEntity second = new InboundOrderEntity(); + second.setId(2L); + page.setRecords(java.util.List.of(first, second)); + when(orders.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page); + when(pallets.selectList(any(Wrapper.class))).thenReturn(java.util.List.of( + allocation(1L, 1, "A0"), + allocation(1L, 2, "A2"), + allocation(2L, 1, "B1"))); + InboundOrderRepository repository = new InboundOrderRepository( + orders, + mock(InboundTransportSnapshotMapper.class), + pallets, + mock(InboundFileMapper.class), + mock(InboundInternalNoteMapper.class), + mock(InboundStatusHistoryMapper.class)); + + var result = repository.page(10001L, new InboundOrderQuery( + null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, 1, 20)); + + assertThat(result.records()).extracting(record -> record.locationCodes()) + .containsExactly("A0、A2", "B1"); + ArgumentCaptor> captor = + ArgumentCaptor.forClass(Wrapper.class); + verify(pallets, times(1)).selectList(captor.capture()); + assertThat(captor.getValue().getCustomSqlSegment()) + .contains("tenant_id") + .contains("inbound_id IN") + .contains("ORDER BY inbound_id ASC,allocation_no ASC"); + } + + private static InboundPalletAllocationEntity allocation( + long inboundId, int allocationNo, String locationCode) { + InboundPalletAllocationEntity entity = new InboundPalletAllocationEntity(); + entity.setInboundId(inboundId); + entity.setAllocationNo(allocationNo); + entity.setLocationCode(locationCode); + return entity; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java new file mode 100644 index 000000000..757deeb90 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java @@ -0,0 +1,29 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("入库审核SQL契约") +class InboundWorkflowSqlContractTest { + @Test + @DisplayName("基础表和增量迁移包含审核历史、幂等、Outbox和放行索引") + void shouldProvideWorkflowPersistenceBoundaries() throws Exception { + String schema = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String migration = new ClassPathResource("db/phase2-inbound-workflow-migration.sql") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(schema) + .contains("CREATE TABLE IF NOT EXISTS fl_measure_review_record") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_idempotency") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_outbox") + .contains("idx_review_exception_release"); + assertThat(migration) + .contains("information_schema.statistics") + .contains("idx_review_exception_release"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/security/MeasurementRequestAuthenticatorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/security/MeasurementRequestAuthenticatorTest.java new file mode 100644 index 000000000..a25ec6c0c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/security/MeasurementRequestAuthenticatorTest.java @@ -0,0 +1,95 @@ +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.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("第三方测量请求签名校验") +class MeasurementRequestAuthenticatorTest { + private static final Instant NOW = Instant.parse("2026-08-01T02:30:00Z"); + private static final String SECRET = "test-secret-at-least-32-bytes-long"; + + @Test + @DisplayName("合法签名按六行规范串校验并占用Nonce") + void shouldAcceptValidSignatureAndClaimNonce() { + MeasurementOpenApiProperties properties = properties(); + NonceReplayGuard guard = (appKey, nonce, ttl) -> true; + MeasurementRequestAuthenticator authenticator = new MeasurementRequestAuthenticator( + properties, guard, Clock.fixed(NOW, ZoneOffset.UTC)); + String body = "{\"eventNo\":\"M1\"}"; + String timestamp = String.valueOf(NOW.toEpochMilli()); + String canonical = MeasurementRequestAuthenticator.canonicalString("POST", + "/openapi/v1/inbound-measurements", timestamp, "nonce-1", "idem-1", + MeasurementRequestAuthenticator.sha256Hex(body)); + String signature = MeasurementRequestAuthenticator.hmacHex(SECRET, canonical); + + AuthenticatedMeasurementApp app = authenticator.authenticate("POST", + "/openapi/v1/inbound-measurements", + new MeasurementRequestHeaders("device-provider", timestamp, "nonce-1", signature, "idem-1"), + body, true); + + assertThat(app.tenantId()).isEqualTo(10001L); + assertThat(app.warehouseBindings()).containsEntry("CN-YW-01", 30001L); + } + + @Test + @DisplayName("非法签名返回统一未认证业务码且不占用Nonce") + void shouldRejectInvalidSignatureBeforeClaimingNonce() { + int[] claims = {0}; + NonceReplayGuard guard = (appKey, nonce, ttl) -> { + claims[0]++; + return true; + }; + MeasurementRequestAuthenticator authenticator = new MeasurementRequestAuthenticator( + properties(), guard, Clock.fixed(NOW, ZoneOffset.UTC)); + + assertThatThrownBy(() -> authenticator.authenticate("POST", "/openapi/v1/inbound-measurements", + new MeasurementRequestHeaders("device-provider", String.valueOf(NOW.toEpochMilli()), + "nonce-1", "00", "idem-1"), "{}", true)) + .isInstanceOf(BizException.class) + .satisfies(exception -> assertThat(((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.UNAUTHORIZED)); + assertThat(claims[0]).isZero(); + } + + @Test + @DisplayName("相同应用重复Nonce被拒绝") + void shouldRejectReplayedNonce() { + MeasurementRequestAuthenticator authenticator = new MeasurementRequestAuthenticator( + properties(), (appKey, nonce, ttl) -> false, Clock.fixed(NOW, ZoneOffset.UTC)); + String timestamp = String.valueOf(NOW.toEpochMilli()); + String body = "{}"; + String canonical = MeasurementRequestAuthenticator.canonicalString("POST", + "/openapi/v1/inbound-measurements", timestamp, "nonce-1", "idem-1", + MeasurementRequestAuthenticator.sha256Hex(body)); + + assertThatThrownBy(() -> authenticator.authenticate("POST", "/openapi/v1/inbound-measurements", + new MeasurementRequestHeaders("device-provider", timestamp, "nonce-1", + MeasurementRequestAuthenticator.hmacHex(SECRET, canonical), "idem-1"), body, true)) + .isInstanceOf(BizException.class) + .satisfies(exception -> assertThat(((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.CONFLICT)); + } + + private MeasurementOpenApiProperties properties() { + MeasurementOpenApiProperties properties = new MeasurementOpenApiProperties(); + properties.setEnabled(true); + MeasurementOpenApiProperties.AppCredential credential = new MeasurementOpenApiProperties.AppCredential(); + credential.setSecret(SECRET); + credential.setTenantId(10001L); + credential.setSubjectId(90001L); + credential.setWarehouseBindings(Map.of("CN-YW-01", 30001L)); + properties.setApps(Map.of("device-provider", credential)); + return properties; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/AutomaticInboundCompletionServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/AutomaticInboundCompletionServiceTest.java new file mode 100644 index 000000000..d5757d5c3 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/AutomaticInboundCompletionServiceTest.java @@ -0,0 +1,89 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.repository.InboundWorkflowRepository; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AutomaticInboundCompletionServiceTest { + private final FbaPricingService pricing = mock(FbaPricingService.class); + private final InboundWorkflowRepository workflow = mock(InboundWorkflowRepository.class); + private final InboundCompletionOrchestrator completion = mock(InboundCompletionOrchestrator.class); + private final AutomaticInboundCompletionService service = + new AutomaticInboundCompletionService(pricing, workflow, completion); + + @Test + void 客服审核通过后自动计费发布并完成入库() { + FeeSnapshotResponse fee = fee(501L); + when(pricing.latest(10001L, 88L)).thenReturn(fee); + when(workflow.findOwned(10001L, 88L)).thenReturn( + order("WAIT_PRICING", "UNPRICED", 4), + order("FEE_CONFIRMING", "CALCULATED", 5), + order("FEE_CONFIRMING", "CONFIRMED", 6), + order("INBOUNDING", "CONFIRMED", 7), + order("INBOUNDED", "SENT", 8)); + + FeeSnapshotResponse result = service.completeApproved(10001L, 88L, 9L, "客服", 30L, 4); + + assertThat(result).isSameAs(fee); + verify(pricing).calculate(10001L, 9L, 88L, null, "auto-88-30-4-calculate"); + verify(pricing).confirm(10001L, 9L, 88L, "auto-88-30-4-confirm"); + verify(completion).beginSendFeeAndInbound(10001L, 9L, "客服", 88L, 501L, 6, + "auto-88-30-4-begin"); + verify(completion).completeSendFeeAndInbound(10001L, 9L, "客服", 88L, 501L, 7, + "auto-88-30-4-complete"); + } + + @Test + void 已完成入库的审核重放只返回最新费用() { + FeeSnapshotResponse fee = fee(501L); + when(workflow.findOwned(10001L, 88L)).thenReturn(order("INBOUNDED", "SENT", 8)); + when(pricing.latest(10001L, 88L)).thenReturn(fee); + + assertThat(service.completeApproved(10001L, 88L, 9L, "客服", 30L, 4)).isSameAs(fee); + + verify(pricing, never()).calculate(anyLong(), anyLong(), anyLong(), + org.mockito.ArgumentMatchers.any(), anyString()); + verify(pricing, never()).confirm(anyLong(), anyLong(), anyLong(), anyString()); + verify(completion, never()).beginSendFeeAndInbound(anyLong(), anyLong(), anyString(), + anyLong(), anyLong(), org.mockito.ArgumentMatchers.anyInt(), anyString()); + } + + @Test + void 中断在入库处理中时续跑完成而不重复计费() { + FeeSnapshotResponse fee = fee(501L); + when(pricing.latest(10001L, 88L)).thenReturn(fee); + when(workflow.findOwned(10001L, 88L)).thenReturn( + order("INBOUNDING", "CONFIRMED", 7), order("INBOUNDED", "SENT", 8)); + + service.completeApproved(10001L, 88L, 9L, "客服", 30L, 4); + + verify(completion).completeSendFeeAndInbound(10001L, 9L, "客服", 88L, 501L, 7, + "auto-88-30-4-complete"); + verify(pricing, never()).calculate(anyLong(), anyLong(), anyLong(), + org.mockito.ArgumentMatchers.any(), anyString()); + verify(pricing, never()).confirm(anyLong(), anyLong(), anyLong(), anyString()); + } + + private static InboundOrderEntity order(String process, String feeStatus, int version) { + InboundOrderEntity order = new InboundOrderEntity(); + order.setProcessStatus(process); + order.setFeeStatus(feeStatus); + order.setVersion(version); + return order; + } + + private static FeeSnapshotResponse fee(long feeId) { + FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class); + when(fee.feeId()).thenReturn(feeId); + return fee; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java new file mode 100644 index 000000000..c9c8cc28c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java @@ -0,0 +1,67 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest; +import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; +import com.baiyue.firstleg.repository.FbaPricingRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("FBA计费基础资料版本") +class FbaMasterDataServiceTest { + private static final LocalDateTime AUGUST = LocalDateTime.of(2026, 8, 1, 0, 0); + + @Test + @DisplayName("半开有效期首尾相接不视为重叠") + void acceptsAdjacentHalfOpenPeriods() { + FbaPricingRepository repository = mock(FbaPricingRepository.class); + CustomerChargeRuleEntity old = activeRule(AUGUST.minusMonths(1), AUGUST); + when(repository.listRules(1L, null)).thenReturn(List.of(old)); + when(repository.listRules(1L, "CR-1")).thenReturn(List.of(old)); + when(repository.nextRuleVersion(1L, "CR-1")).thenReturn(2); + doAnswer(invocation -> { ((CustomerChargeRuleEntity) invocation.getArgument(0)).setId(10L); return null; }) + .when(repository).insertRule(any()); + + CustomerChargeRuleEntity created = new FbaMasterDataService(repository) + .createRuleVersion(1L, 2L, request(AUGUST, AUGUST.plusMonths(1))); + + assertThat(created.getId()).isEqualTo(10L); + assertThat(created.getRuleVersion()).isEqualTo(2); + } + + @Test + @DisplayName("任意重叠有效期在保存阶段拒绝避免多规则匹配") + void rejectsOverlappingPeriods() { + FbaPricingRepository repository = mock(FbaPricingRepository.class); + when(repository.listRules(1L, null)).thenReturn(List.of( + activeRule(AUGUST, AUGUST.plusMonths(1)))); + + assertThatThrownBy(() -> new FbaMasterDataService(repository) + .createRuleVersion(1L, 2L, request(AUGUST.plusDays(1), AUGUST.plusMonths(2)))) + .isInstanceOf(BizException.class) + .hasMessageContaining("有效期重叠"); + } + + private CustomerRuleVersionRequest request(LocalDateTime from, LocalDateTime to) { + return new CustomerRuleVersionRequest("CR-1", 100L, "客户A", "FBA_SEA", "KG", + "TAX_INCLUDED", new BigDecimal("167"), 3, "CEILING", "USD", from, to, null); + } + + private CustomerChargeRuleEntity activeRule(LocalDateTime from, LocalDateTime to) { + CustomerChargeRuleEntity value = new CustomerChargeRuleEntity(); + value.setRuleCode("CR-1"); value.setRuleVersion(1); value.setCustomerId(100L); + value.setBusinessType("FBA_SEA"); value.setTaxMode("TAX_INCLUDED"); value.setStatus("ACTIVE"); + value.setEffectiveFrom(from); value.setEffectiveTo(to); return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java new file mode 100644 index 000000000..8f4f253ab --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -0,0 +1,288 @@ +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.CarrierFbaRateEntity; +import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; +import com.baiyue.firstleg.entity.InboundFeeEntity; +import com.baiyue.firstleg.entity.InboundFeeItemEntity; +import com.baiyue.firstleg.entity.InboundPricingEntity; +import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; +import com.baiyue.firstleg.repository.FbaPricingRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("FBA费用状态机") +class FbaPricingServiceTest { + private static final long TENANT = 10001L; + private static final long OPERATOR = 20001L; + private static final long INBOUND = 30001L; + private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 3, 10, 0); + + @Test + @DisplayName("包税KG按配置系数取体积重较大值并按规则向上舍入") + void calculatesTaxIncludedKgWithConfiguredFactorAndPrecision() { + FbaPricingRepository repository = readyRepository(); + InboundPricingEntity inbound = inbound("100.000", "1.0001"); + CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING"); + CarrierFbaRateEntity rate = rate("KG", "2.345678", "10.00"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule)); + when(repository.matchRates(inbound, "KG", NOW)).thenReturn(List.of(rate)); + + FeeSnapshotResponse response = service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-1"); + + assertThat(response.convertedQuantity()).isEqualByComparingTo("167.017"); + assertThat(response.chargeableQuantity()).isEqualByComparingTo("167.017"); + assertThat(response.subtotalAmount()).isEqualByComparingTo("391.77"); + assertThat(response.totalAmount()).isEqualByComparingTo("401.77"); + assertThat(response.nextProcessStatus()).isEqualTo("FEE_CONFIRMING"); + } + + @Test + @DisplayName("不包税CBM按配置系数折算且精度边界向上取整") + void calculatesTaxExcludedCbmAtRoundingBoundary() { + FbaPricingRepository repository = readyRepository(); + InboundPricingEntity inbound = inbound("363.001", "0.5000"); + CustomerChargeRuleEntity rule = rule("CBM", "363.000000", 4, "CEILING"); + CarrierFbaRateEntity rate = rate("CBM", "100.000000", "0.00"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule)); + when(repository.matchRates(inbound, "CBM", NOW)).thenReturn(List.of(rate)); + + FeeSnapshotResponse response = service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-2"); + + assertThat(response.convertedQuantity()).isEqualByComparingTo("1.0001"); + assertThat(response.chargeableQuantity()).isEqualByComparingTo("1.0001"); + assertThat(response.totalAmount()).isEqualByComparingTo("100.01"); + } + + @Test + @DisplayName("审核未通过的测量快照禁止计费") + void rejectsUnapprovedMeasurementSnapshot() { + FbaPricingRepository repository = readyRepository(); + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setMeasureReviewStatus("REJECTED"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + + assertThatThrownBy(() -> service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-3")) + .isInstanceOf(BizException.class) + .hasMessageContaining("审核通过"); + verify(repository, never()).matchRules(any(), any()); + } + + @Test + @DisplayName("无客户规则和多客户规则都明确拒绝") + void rejectsMissingAndAmbiguousRules() { + FbaPricingRepository missing = readyRepository(); + InboundPricingEntity inbound = inbound("10", "1"); + when(missing.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(missing.matchRules(inbound, NOW)).thenReturn(List.of()); + assertThatThrownBy(() -> service(missing).calculate(TENANT, OPERATOR, INBOUND, NOW, "none")) + .isInstanceOf(BizException.class).hasMessageContaining("未匹配到有效客户计费规则"); + + FbaPricingRepository ambiguous = readyRepository(); + when(ambiguous.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(ambiguous.matchRules(inbound, NOW)).thenReturn(List.of( + rule("KG", "167", 3, "CEILING"), rule("KG", "168", 3, "CEILING"))); + assertThatThrownBy(() -> service(ambiguous).calculate(TENANT, OPERATOR, INBOUND, NOW, "many")) + .isInstanceOf(BizException.class).hasMessageContaining("多条有效客户计费规则"); + } + + @Test + @DisplayName("同一幂等键重复试算直接返回原费用快照") + void returnsOriginalSnapshotForRepeatedCalculation() { + FbaPricingRepository repository = mock(FbaPricingRepository.class); + InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CALCULATE", "same")).thenReturn(completed); + when(repository.findFee(TENANT, 501L)).thenReturn(savedFee("CALCULATED")); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound("10", "1")); + InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00")); + when(repository.findBaseItem(TENANT, 501L)).thenReturn(item); + + FeeSnapshotResponse response = service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "same"); + + assertThat(response.feeId()).isEqualTo(501L); + verify(repository, never()).matchRules(any(), any()); + verify(repository, never()).insertFee(any()); + } + + @Test + @DisplayName("重复发送只返回首次结果且不会重复写Outbox") + void sendIsIdempotentAndDoesNotDuplicateOutbox() { + FbaPricingRepository repository = mock(FbaPricingRepository.class); + InboundPricingEntity inbound = inbound("10", "1"); + InboundFeeEntity confirmed = savedFee("CONFIRMED"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(confirmed); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_SEND", "send-once")) + .thenReturn(null, completed("FEE_SEND", 501L)); + when(repository.sendFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1); + when(repository.markInboundWaitingPallet(TENANT, INBOUND)).thenReturn(1); + when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1); + when(repository.findFee(TENANT, 501L)).thenReturn(confirmed); + InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00")); + when(repository.findBaseItem(TENANT, 501L)).thenReturn(item); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; }) + .when(repository).insertIdempotency(any()); + + FbaPricingService service = service(repository); + service.send(TENANT, OPERATOR, INBOUND, "send-once"); + service.send(TENANT, OPERATOR, INBOUND, "send-once"); + + verify(repository, times(1)).insertOutbox(any()); + verify(repository, times(1)).sendFee(TENANT, 501L, OPERATOR, NOW); + } + + @Test + @DisplayName("同一幂等键用于不同试算时点会冲突") + void rejectsReusedIdempotencyKeyWithDifferentRequest() { + FbaPricingRepository repository = mock(FbaPricingRepository.class); + InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L); + completed.setRequestHash("different"); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CALCULATE", "same")).thenReturn(completed); + + assertThatThrownBy(() -> service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "same")) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + } + + @Test + @DisplayName("已发布费用可生成新版本且不回退已入库状态") + void adjustsPublishedFeeWithoutRollingBackInbound() { + FbaPricingRepository repository = mock(FbaPricingRepository.class); + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("INBOUNDED"); + inbound.setFeeStatus("SENT"); + InboundFeeEntity current = savedFee("SENT"); + AtomicReference inserted = new AtomicReference<>(); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_ADJUST", "adjust-1")).thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(901L); return null; }) + .when(repository).insertIdempotency(any()); + when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); + when(repository.supersedeSent(TENANT, 501L)).thenReturn(1); + doAnswer(invocation -> { + InboundFeeEntity fee = invocation.getArgument(0); + fee.setId(502L); + inserted.set(fee); + return null; + }).when(repository).insertFee(any()); + when(repository.completeIdempotency(any(), eq(502L), eq(NOW))).thenReturn(1); + when(repository.findFee(TENANT, 502L)).thenAnswer(invocation -> inserted.get()); + + FeeSnapshotResponse response = service(repository).adjustPublished(TENANT, OPERATOR, INBOUND, + new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( + new BigDecimal("100.00"), new BigDecimal("20.00"), "客服修正"), "adjust-1"); + + assertThat(response.feeVersion()).isEqualTo(2); + assertThat(response.feeStatus()).isEqualTo("SENT"); + assertThat(response.totalAmount()).isEqualByComparingTo("120.00"); + assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED"); + assertThat(inbound.getProcessStatus()).isEqualTo("INBOUNDED"); + verify(repository).supersedeSent(TENANT, 501L); + verify(repository).insertOutbox(any()); + } + + private FbaPricingRepository readyRepository() { + FbaPricingRepository repository = mock(FbaPricingRepository.class); + when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; }) + .when(repository).insertIdempotency(any()); + doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(501L); return null; }) + .when(repository).insertFee(any()); + when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(1); + when(repository.markInboundCalculated(eq(TENANT), eq(INBOUND), any(), any())).thenReturn(1); + when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1); + return repository; + } + + private FbaPricingService service(FbaPricingRepository repository) { + return new FbaPricingService(repository, + Clock.fixed(Instant.parse("2026-08-03T02:00:00Z"), ZoneId.of("Asia/Shanghai"))); + } + + private InboundPricingEntity inbound(String weight, String volume) { + InboundPricingEntity value = new InboundPricingEntity(); + value.setId(INBOUND); value.setTenantId(TENANT); value.setInboundCode("RK-001"); + value.setProcessStatus("WAIT_PRICING"); value.setMeasureReviewStatus("APPROVED"); + value.setFeeStatus("UNPRICED"); value.setCustomerId(11L); value.setCustomerName("客户A"); + value.setWarehouseId(21L); value.setWarehouseName("义乌仓"); value.setBusinessType("FBA_SEA"); + value.setChannelCode("SEA-TAX"); value.setCarrierId(31L); value.setCarrierName("船司A"); + value.setTaxMode("TAX_INCLUDED"); value.setDestinationType("FBA"); value.setDestinationCode("ONT8"); + value.setActualGrossWeightKg(new BigDecimal(weight)); value.setActualVolumeCbm(new BigDecimal(volume)); + return value; + } + + private CustomerChargeRuleEntity rule(String mode, String factor, int scale, String rounding) { + CustomerChargeRuleEntity value = new CustomerChargeRuleEntity(); + value.setId(101L); value.setRuleCode("CR-001"); value.setRuleVersion(1); + value.setChargeMode(mode); value.setConversionFactor(new BigDecimal(factor)); + value.setRoundingScale(scale); value.setRoundingMode(rounding); value.setCurrency("USD"); + return value; + } + + private CarrierFbaRateEntity rate(String unit, String price, String additional) { + CarrierFbaRateEntity value = new CarrierFbaRateEntity(); + value.setId(201L); value.setRateCode("RT-001"); value.setRateVersion(1); + value.setOriginWarehouseId(21L); value.setOriginWarehouseName("义乌仓"); + value.setChannelCode("SEA-TAX"); value.setCarrierId(31L); value.setCarrierName("船司A"); + value.setDestinationCode("ONT8"); value.setChargeUnit(unit); value.setUnitPrice(new BigDecimal(price)); + value.setCurrency("USD"); value.setAdditionalFee(new BigDecimal(additional)); + value.setEffectiveFrom(NOW.minusDays(1)); value.setEffectiveTo(NOW.plusDays(1)); + return value; + } + + private InboundPricingIdempotencyEntity completed(String operation, long feeId) { + InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity(); + value.setId(900L); value.setOperation(operation); value.setStatus("COMPLETED"); + value.setResultResourceId(feeId); + // 试算重复用例的摘要由服务算法固定;其余用例在首次调用后才返回该记录。 + value.setRequestHash("FEE_SEND".equals(operation) + ? "3be2051b9bc4c06a163cb3099fd7b3f828b51307cc54d3cfc232c11353c61046" + : "8aa8a9861d4cac0c6001bb902754c152c6211ba57e1fad9160b1caef651e3d65"); + return value; + } + + private InboundFeeEntity savedFee(String status) { + InboundFeeEntity value = new InboundFeeEntity(); + value.setId(501L); value.setTenantId(TENANT); value.setInboundId(INBOUND); value.setFeeNo("FBAF-30001-1"); + value.setFeeVersion(1); value.setFeeStatus(status); value.setChargeRuleId(101L); + value.setRuleCode("CR-001"); value.setChargeRuleVersion(1); value.setCarrierRateId(201L); + value.setRateCode("RT-001"); value.setCarrierRateVersion(1); value.setCustomerId(11L); + value.setCustomerName("客户A"); value.setOriginWarehouseId(21L); value.setOriginWarehouseName("义乌仓"); + value.setBusinessType("FBA_SEA"); value.setChannelCode("SEA-TAX"); value.setCarrierId(31L); + value.setCarrierName("船司A"); value.setDestinationCode("ONT8"); value.setTaxMode("TAX_INCLUDED"); + value.setChargeMode("KG"); value.setConversionFactor(new BigDecimal("167")); value.setRoundingScale(3); + value.setRoundingMode("CEILING"); value.setActualWeightKg(new BigDecimal("10")); + value.setActualVolumeCbm(BigDecimal.ONE); value.setConvertedQuantity(new BigDecimal("167")); + value.setChargeableQuantity(new BigDecimal("167")); value.setChargeUnit("KG"); + value.setSubtotalAmount(new BigDecimal("334")); value.setAdditionalAmount(BigDecimal.ZERO); + value.setTotalAmount(new BigDecimal("334")); value.setCurrency("USD"); value.setCalculatedAt(NOW); + return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java new file mode 100644 index 000000000..0585b4a89 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java @@ -0,0 +1,127 @@ +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.repository.InboundWorkflowRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("发送费用并入库状态编排") +class InboundCompletionOrchestratorTest { + @Test + @DisplayName("审核通过且费用和库位引用已准备时进入INBOUNDING") + void shouldBeginInboundAfterAllGuardsPass() { + Fixture fixture = fixture("APPROVED", false); + when(fixture.repository.beginInbound(10001L, 10L, 9L, 4)).thenReturn(1); + + fixture.service.beginSendFeeAndInbound(10001L, 9L, "操作员", 10L, 30L, 4, "send-1"); + + verify(fixture.repository).beginInbound(10001L, 10L, 9L, 4); + verify(fixture.repository).insertHistory(any()); + } + + @Test + @DisplayName("测量驳回且无异常放行记录时禁止发送费用") + void shouldBlockRejectedMeasurementWithoutRelease() { + Fixture fixture = fixture("REJECTED", true); + when(fixture.repository.hasExceptionRelease(10001L, 10L)).thenReturn(false); + + assertThatThrownBy(() -> fixture.service.beginSendFeeAndInbound( + 10001L, 9L, "操作员", 10L, 30L, 4, "send-1")) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(fixture.repository, never()).beginInbound(anyLong(), anyLong(), anyLong(), anyInt()); + } + + @Test + @DisplayName("异常放行标记必须与费用快照一致") + void shouldRequireFeeSnapshotReleaseFlag() { + Fixture fixture = fixture("REJECTED", false); + when(fixture.repository.hasExceptionRelease(10001L, 10L)).thenReturn(true); + + assertThatThrownBy(() -> fixture.service.beginSendFeeAndInbound( + 10001L, 9L, "操作员", 10L, 30L, 4, "send-1")) + .isInstanceOf(BizException.class) + .hasMessageContaining("异常放行标记"); + } + + @Test + @DisplayName("完成阶段原子发送费用、完成入库并写待投递事件") + void shouldCompleteInboundAndWriteOutbox() { + Fixture fixture = fixture("APPROVED", false); + fixture.order.setProcessStatus("INBOUNDING"); + when(fixture.repository.completeInbound(10001L, 10L, 9L, 5)).thenReturn(1); + when(fixture.repository.markFeeSent(10001L, 10L, 30L, 9L)).thenReturn(1); + + fixture.service.completeSendFeeAndInbound( + 10001L, 9L, "操作员", 10L, 30L, 5, "complete-1"); + + verify(fixture.repository).completeInbound(10001L, 10L, 9L, 5); + verify(fixture.repository).markFeeSent(10001L, 10L, 30L, 9L); + verify(fixture.repository).insertOutbox(any()); + } + + @Test + @DisplayName("条件更新失败时报告并发冲突且不发送费用") + void shouldRejectConcurrentCompletion() { + Fixture fixture = fixture("APPROVED", false); + fixture.order.setProcessStatus("INBOUNDING"); + when(fixture.repository.completeInbound(10001L, 10L, 9L, 5)).thenReturn(0); + + assertThatThrownBy(() -> fixture.service.completeSendFeeAndInbound( + 10001L, 9L, "操作员", 10L, 30L, 5, "complete-1")) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(fixture.repository, never()).markFeeSent(anyLong(), anyLong(), anyLong(), anyLong()); + verify(fixture.repository, never()).insertOutbox(any()); + } + + @Test + @DisplayName("只有仓库预占已补偿后才通过显式方法回退") + void shouldRollbackOnlyInboundingState() { + Fixture fixture = fixture("APPROVED", false); + when(fixture.repository.rollbackInbound(10001L, 10L, 9L, 5)).thenReturn(1); + + fixture.service.rollbackSendFeeAndInbound( + 10001L, 9L, "操作员", 10L, 5, "仓库确认失败且已释放预占", "rollback-1"); + + verify(fixture.repository).rollbackInbound(10001L, 10L, 9L, 5); + verify(fixture.repository).insertHistory(any()); + } + + private Fixture fixture(String reviewStatus, boolean feeRelease) { + InboundWorkflowRepository repository = mock(InboundWorkflowRepository.class); + InboundIdempotencyService idempotency = mock(InboundIdempotencyService.class); + when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) + .thenReturn(new InboundIdempotencyService.Context(1L, false)); + InboundOrderEntity order = new InboundOrderEntity(); + order.setProcessStatus("FEE_CONFIRMING"); + order.setFeeStatus("CONFIRMED"); + order.setMeasureReviewStatus(reviewStatus); + InboundFeeEntity fee = new InboundFeeEntity(); + fee.setExceptionRelease(feeRelease); + when(repository.findOwned(10001L, 10L)).thenReturn(order); + when(repository.findConfirmedFee(10001L, 10L, 30L)).thenReturn(fee); + when(repository.countPreparedAllocations(10001L, 10L)).thenReturn(1L); + return new Fixture(new InboundCompletionOrchestrator(repository, idempotency), repository, order); + } + + private record Fixture(InboundCompletionOrchestrator service, + InboundWorkflowRepository repository, + InboundOrderEntity order) { + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundIdempotencyServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundIdempotencyServiceTest.java new file mode 100644 index 000000000..5e7d7629c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundIdempotencyServiceTest.java @@ -0,0 +1,75 @@ +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.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DuplicateKeyException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("入库写操作幂等边界") +class InboundIdempotencyServiceTest { + @Test + @DisplayName("同键同请求成功记录可重放") + void shouldReplayCompletedRequest() { + InboundWorkflowRepository repository = mock(InboundWorkflowRepository.class); + doThrow(new DuplicateKeyException("duplicate")).when(repository).insertIdempotency(any()); + InboundIdempotencyEntity existing = existing("SUCCESS"); + when(repository.findIdempotency(10001L, "MEASURE_APPROVE", 9L, "key-1")) + .thenReturn(existing); + InboundIdempotencyService service = new InboundIdempotencyService(repository); + + InboundIdempotencyService.Context context = service.begin( + 10001L, 9L, 10L, "MEASURE_APPROVE", "key-1", "10|20|3|"); + + assertThat(context.replayed()).isTrue(); + } + + @Test + @DisplayName("同键不同请求返回冲突") + void shouldRejectDifferentRequestForSameKey() { + InboundWorkflowRepository repository = mock(InboundWorkflowRepository.class); + doThrow(new DuplicateKeyException("duplicate")).when(repository).insertIdempotency(any()); + InboundIdempotencyEntity existing = existing("SUCCESS"); + existing.setRequestHash("different-hash"); + when(repository.findIdempotency(10001L, "MEASURE_APPROVE", 9L, "key-1")) + .thenReturn(existing); + InboundIdempotencyService service = new InboundIdempotencyService(repository); + + assertThatThrownBy(() -> service.begin( + 10001L, 9L, 10L, "MEASURE_APPROVE", "key-1", "10|20|4|")) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + } + + @Test + @DisplayName("新请求完成时只允许PROCESSING条件更新一次") + void shouldCompleteProcessingRecordOnce() { + InboundWorkflowRepository repository = mock(InboundWorkflowRepository.class); + when(repository.completeIdempotency(1L, "{\"inboundId\":\"10\"}")).thenReturn(1); + InboundIdempotencyService service = new InboundIdempotencyService(repository); + + service.complete(new InboundIdempotencyService.Context(1L, false), 10L); + + verify(repository).completeIdempotency(1L, "{\"inboundId\":\"10\"}"); + } + + private InboundIdempotencyEntity existing(String status) { + InboundIdempotencyEntity record = new InboundIdempotencyEntity(); + record.setId(1L); + record.setStatus(status); + // 由被测服务对规范化请求计算的 SHA-256。 + record.setRequestHash("8482715c52a4611811574b1ca2dcbf031c765de767e052d4bb6d62139c10ee34"); + return record; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java new file mode 100644 index 000000000..b55d6cc0c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java @@ -0,0 +1,127 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.EventResponse; +import com.baiyue.firstleg.entity.InboundMeasurementEntity; +import com.baiyue.firstleg.entity.InboundOrderLookupEntity; +import com.baiyue.firstleg.entity.ThirdPartyCallLogEntity; +import com.baiyue.firstleg.repository.InboundMeasurementOpenApiRepository; +import com.baiyue.firstleg.security.AuthenticatedMeasurementApp; +import com.baiyue.firstleg.security.MeasurementRequestAuthenticator; +import com.baiyue.firstleg.security.MeasurementRequestHeaders; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.Validation; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.OffsetDateTime; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@DisplayName("第三方测量开放接口服务") +class InboundMeasurementOpenApiServiceTest { + private static final String BODY = """ + {"eventNo":"M202608010001","warehouseCode":"CN-YW-01","inboundCode":"RK202608010001", + "deviceCode":"DEVICE-01","measuredAt":"2026-08-01T10:30:00+08:00", + "items":[{"itemNo":"001","lengthCm":50.20,"widthCm":40.10,"heightCm":30.00, + "quantity":5,"boxSpecification":"50.2x40.1x30cm"}]} + """; + + @Test + @DisplayName("回调只写批次明细和幂等结果,不存在入库主单update入口") + void shouldPersistMeasurementsWithoutUpdatingInboundOrder() { + Fixture fixture = fixture(); + InboundOrderLookupEntity inbound = inbound(); + when(fixture.repository.findInbound(10001L, "RK202608010001")).thenReturn(inbound); + + EventResponse response = fixture.service.receive(headers("nonce-1"), BODY); + + assertThat(response.accepted()).isTrue(); + assertThat(response.status()).isEqualTo("RECEIVED"); + verify(fixture.repository).insertMeasurement(any(InboundMeasurementEntity.class)); + verify(fixture.repository).insertItem(any()); + assertThatNoException().isThrownBy(() -> + InboundMeasurementOpenApiRepository.class.getDeclaredMethod("findInbound", long.class, String.class)); + assertThat(InboundMeasurementOpenApiRepository.class.getDeclaredMethods()) + .noneMatch(method -> method.getName().toLowerCase().contains("updateinbound")); + } + + @Test + @DisplayName("重复Idempotency-Key且报文一致时返回首次结果,不重复写测量") + void shouldReturnFirstResultForDuplicateCallback() throws Exception { + Fixture fixture = fixture(); + ObjectMapper mapper = objectMapper(); + EventResponse first = new EventResponse("M202608010001", "RK202608010001", true, + "RECEIVED", OffsetDateTime.parse("2026-08-01T02:30:00Z")); + ThirdPartyCallLogEntity existing = new ThirdPartyCallLogEntity(); + existing.setRequestHash(MeasurementRequestAuthenticator.sha256Hex(BODY)); + existing.setResultJson(mapper.writeValueAsString(first)); + existing.setProcessingStatus("ACCEPTED"); + when(fixture.logs.claimIdempotency(1L)).thenReturn(false); + when(fixture.logs.findIdempotencyOwner(10001L, "device-provider", "idem-1")).thenReturn(existing); + + EventResponse duplicate = fixture.service.receive(headers("nonce-2"), BODY); + + assertThat(duplicate).isEqualTo(first); + verify(fixture.repository, never()).insertMeasurement(any()); + verify(fixture.repository, never()).insertItem(any()); + } + + private Fixture fixture() { + InboundMeasurementOpenApiRepository repository = mock(InboundMeasurementOpenApiRepository.class); + MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class); + ThirdPartyCallLogService logs = mock(ThirdPartyCallLogService.class); + TransactionTemplate transactions = mock(TransactionTemplate.class); + when(logs.start(anyString(), anyString(), any(), anyString())).thenReturn(1L); + when(logs.claimIdempotency(1L)).thenReturn(true); + when(authenticator.authenticate(anyString(), anyString(), any(), anyString(), anyBoolean())) + .thenReturn(new AuthenticatedMeasurementApp("device-provider", 10001L, 90001L, + Map.of("CN-YW-01", 30001L))); + when(transactions.execute(any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(mock(TransactionStatus.class)); + }); + InboundMeasurementOpenApiService service = new InboundMeasurementOpenApiService(repository, + authenticator, logs, objectMapper(), + Validation.buildDefaultValidatorFactory().getValidator(), transactions); + return new Fixture(repository, logs, service); + } + + private ObjectMapper objectMapper() { + return new ObjectMapper().findAndRegisterModules(); + } + + private InboundOrderLookupEntity inbound() { + InboundOrderLookupEntity inbound = new InboundOrderLookupEntity(); + inbound.setId(70001L); + inbound.setTenantId(10001L); + inbound.setInboundCode("RK202608010001"); + inbound.setWarehouseId(30001L); + inbound.setProcessStatus("WAIT_MEASURE"); + inbound.setDeleted(0); + return inbound; + } + + private MeasurementRequestHeaders headers(String nonce) { + return new MeasurementRequestHeaders("device-provider", "1785551400000", nonce, "signature", "idem-1"); + } + + private record Fixture(InboundMeasurementOpenApiRepository repository, + ThirdPartyCallLogService logs, + InboundMeasurementOpenApiService service) { + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java new file mode 100644 index 000000000..d3ac61d4d --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -0,0 +1,174 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.InboundMeasurementRequests; +import com.baiyue.firstleg.dto.InboundMeasurementResponses; +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.repository.InboundMeasurementRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("平台入库测量应用服务") +class InboundMeasurementServiceTest { + private InboundMeasurementRepository repository; + private InboundMeasurementService service; + + @BeforeEach + void setUp() { + repository = mock(InboundMeasurementRepository.class); + service = new InboundMeasurementService(repository, mock(MeasurementBatchService.class)); + } + + @Test + void correctingWeightKeepsOcrOriginalAndWritesImmutableRevision() { + InboundOrderEntity inbound = inbound("MEASURING"); + InboundWeightResultEntity weight = weight(20L, "12.500", "KG", "PENDING_CONFIRM", 4); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + + service.correctWeightResult(1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.CorrectWeightResultRequest( + 4, new BigDecimal("13.200"), "kg", "秤面显示修正")); + + assertThat(weight.getOcrWeightValue()).isEqualByComparingTo("12.500"); + assertThat(weight.getOcrWeightUnit()).isEqualTo("KG"); + assertThat(weight.getAdjustedWeightValue()).isEqualByComparingTo("13.200"); + ArgumentCaptor revision = + ArgumentCaptor.forClass(InboundWeightRevisionEntity.class); + verify(repository).insertRevision(revision.capture()); + assertThat(revision.getValue().getRevisionNo()).isEqualTo(5); + assertThat(revision.getValue().getBeforeWeightValue()).isEqualByComparingTo("12.500"); + assertThat(revision.getValue().getAfterWeightValue()).isEqualByComparingTo("13.200"); + } + + @Test + void detailReturnsStandardPalletRecommendationWithoutAllocatingPallet() { + when(repository.findInbound(1L, 10L)).thenReturn(inbound("MEASURE_PENDING")); + when(repository.listWeights(1L, 10L)).thenReturn(List.of()); + + InboundMeasurementResponses.Detail result = service.detail(1L, 10L); + + assertThat(result.recommendedPalletType()).isEqualTo("STANDARD"); + verify(repository, never()).updateInbound(any()); + } + + @Test + void confirmingLbWeightConvertsToKgAndRecalculatesOnlyConfirmedImages() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity current = weight(20L, "10.000", "LB", "PENDING_CONFIRM", 0); + InboundWeightResultEntity previous = weight(21L, "2.000", "KG", "CONFIRMED", 1); + previous.setFinalWeightKg(new BigDecimal("2.000")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(current); + when(repository.updateWeight(current)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(current, previous)); + when(repository.updateInbound(inbound)).thenReturn(true); + + InboundMeasurementResponses.WeightResult response = service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(0)); + + assertThat(response.finalWeightKg()).isEqualByComparingTo("4.536"); + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("6.536"); + assertThat(inbound.getProcessStatus()).isEqualTo("MEASURING"); + verify(repository).insertRevision(any(InboundWeightRevisionEntity.class)); + } + + @Test + void submitAdvancesOnlyToMeasurePendingAfterAllWeightsConfirmed() { + InboundOrderEntity inbound = inbound("MEASURING"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + measurement.setTenantId(1L); + measurement.setInboundId(10L); + measurement.setStatus("DRAFT"); + measurement.setVersion(2); + measurement.setTotalPackageCount(3); + measurement.setTotalVolumeCbm(new BigDecimal("1.2345")); + measurement.setVolumeWeightKg(new BigDecimal("206.162")); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setSourceImageFileId(500L); + InboundWeightResultEntity weight = weight(20L, "20.000", "KG", "CONFIRMED", 1); + weight.setFinalWeightKg(new BigDecimal("20.000")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(repository.updateMeasurement(measurement)).thenReturn(true); + when(repository.updateInbound(inbound)).thenReturn(true); + + InboundMeasurementResponses.MeasurementSubmitted result = service.submit( + 1L, 10L, 9L, new InboundMeasurementRequests.SubmitRequest(30L, 2)); + + assertThat(result.processStatus()).isEqualTo("MEASURE_PENDING"); + assertThat(result.recommendedPalletType()).isEqualTo("STANDARD"); + assertThat(inbound.getMeasureReviewStatus()).isEqualTo("PENDING"); + assertThat(measurement.getStatus()).isEqualTo("SUBMITTED"); + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("20.000"); + assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("1.2345"); + } + + @Test + void submitRejectsAnyUnconfirmedWeightWithoutChangingState() { + InboundOrderEntity inbound = inbound("MEASURING"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + measurement.setStatus("DRAFT"); + measurement.setVersion(0); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setSourceImageFileId(500L); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); + when(repository.listWeights(1L, 10L)).thenReturn(List.of( + weight(20L, "20", "KG", "PENDING_CONFIRM", 0))); + + assertThatThrownBy(() -> service.submit(1L, 10L, 9L, + new InboundMeasurementRequests.SubmitRequest(30L, 0))) + .isInstanceOf(BizException.class) + .hasMessageContaining("必须先完成确认"); + verify(repository, never()).updateMeasurement(any()); + verify(repository, never()).updateInbound(any()); + } + + private InboundOrderEntity inbound(String status) { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(10L); + inbound.setTenantId(1L); + inbound.setProcessStatus(status); + inbound.setVersion(0); + return inbound; + } + + private InboundWeightResultEntity weight(long id, String value, String unit, + String status, int version) { + InboundWeightResultEntity weight = new InboundWeightResultEntity(); + weight.setId(id); + weight.setTenantId(1L); + weight.setInboundId(10L); + weight.setFileId(id + 100L); + weight.setOcrWeightValue(new BigDecimal(value)); + weight.setOcrWeightUnit(unit); + weight.setRecognitionStatus(status); + weight.setVersion(version); + return weight; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderQueryServiceTest.java new file mode 100644 index 000000000..4d214fe48 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderQueryServiceTest.java @@ -0,0 +1,83 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.domain.InboundListTab; +import com.baiyue.firstleg.dto.InboundOrderDetail; +import com.baiyue.firstleg.dto.InboundOrderQuery; +import com.baiyue.firstleg.dto.InboundOrderSummary; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.repository.InboundOrderRepository; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class InboundOrderQueryServiceTest { + @Test + void shouldKeepV2TabMappingWithoutCustomerConfirmation() { + assertThat(InboundListTab.WAIT_MEASURE.processStatuses()) + .containsExactly("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); + assertThat(InboundListTab.WAIT_REVIEW.processStatuses()).containsExactly("MEASURE_PENDING"); + assertThat(InboundListTab.WAIT_FEE_SEND.processStatuses()) + .containsExactly("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING"); + assertThat(InboundListTab.INBOUNDED.processStatuses()).containsExactly("INBOUNDED"); + assertThat(List.of(InboundListTab.values())).extracting(Enum::name) + .doesNotContain("WAIT_CUSTOMER_CONFIRM"); + } + + @Test + void shouldRejectConflictingTabAndStatus() { + InboundOrderRepository repository = mock(InboundOrderRepository.class); + InboundOrderQuery query = query("WAIT_MEASURE", InboundListTab.WAIT_MEASURE, null, null); + assertThatThrownBy(() -> new InboundOrderQueryService(repository).page(10001L, query)) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.BAD_REQUEST); + } + + @Test + void shouldRejectReversedArrivalRange() { + InboundOrderRepository repository = mock(InboundOrderRepository.class); + LocalDateTime now = LocalDateTime.now(); + assertThatThrownBy(() -> new InboundOrderQueryService(repository) + .page(10001L, query(null, null, now, now.minusDays(1)))) + .isInstanceOf(BizException.class); + } + + @Test + void shouldLoadDetailOnlyAfterTenantOwnedOrderExists() { + InboundOrderRepository repository = mock(InboundOrderRepository.class); + InboundOrderEntity order = new InboundOrderEntity(); + order.setId(9L); + InboundOrderDetail detail = new InboundOrderDetail(null, null, List.of(), List.of(), List.of(), List.of()); + when(repository.findOwned(10001L, 9L)).thenReturn(order); + when(repository.detail(10001L, order)).thenReturn(detail); + + assertThat(new InboundOrderQueryService(repository).detail(10001L, 9L)).isSameAs(detail); + verify(repository).findOwned(10001L, 9L); + verify(repository).detail(10001L, order); + } + + @Test + void shouldReturnBusinessNotFoundForCrossTenantOrMissingOrder() { + InboundOrderRepository repository = mock(InboundOrderRepository.class); + assertThatThrownBy(() -> new InboundOrderQueryService(repository).detail(10002L, 9L)) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.NOT_FOUND); + } + + private static InboundOrderQuery query(String status, InboundListTab tab, + LocalDateTime from, LocalDateTime to) { + return new InboundOrderQuery(null, null, status, null, null, null, null, tab, + from, to, null, null, null, null, null, null, null, 1, 20); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java new file mode 100644 index 000000000..808940ff0 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java @@ -0,0 +1,120 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.MeasurementBatchCommand; +import com.baiyue.firstleg.entity.InboundMeasurementEntity; +import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.repository.InboundMeasurementRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("测量批次共享领域服务") +class MeasurementBatchServiceTest { + private InboundMeasurementRepository repository; + private MeasurementBatchService service; + + @BeforeEach + void setUp() { + repository = mock(InboundMeasurementRepository.class); + service = new MeasurementBatchService(repository); + when(repository.findInbound(1L, 10L)).thenReturn(inbound()); + org.mockito.Mockito.doAnswer(invocation -> { + InboundMeasurementEntity entity = invocation.getArgument(0); + entity.setId(100L); + return null; + }).when(repository).insertMeasurement(any()); + } + + @Test + void manualAndExternalUseSameTablesAndExternalSaveNeverAdvancesInbound() { + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, null, 0, "THIRD_PARTY", "DEVICE_VENDOR", "EVENT-001", + "D-01", "YW-01", LocalDateTime.now(), "{}", null, null, null, + null, 0L, List.of(item(null))); + + InboundMeasurementEntity saved = service.saveBatch(command); + + assertThat(saved.getSourceType()).isEqualTo("THIRD_PARTY"); + assertThat(saved.getStatus()).isEqualTo("RECEIVED"); + assertThat(saved.getTotalPackageCount()).isEqualTo(2); + ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), items.capture()); + assertThat(items.getValue().get(0).getUnitVolumeCbm()).isEqualByComparingTo("0.0240"); + assertThat(items.getValue().get(0).getTotalVolumeCbm()).isEqualByComparingTo("0.0480"); + assertThat(items.getValue().get(0).getGirthCm()).isEqualByComparingTo("140.000"); + assertThat(items.getValue().get(0).getUnitWeightKg()).isNull(); + verify(repository, never()).updateInbound(any()); + } + + @Test + void manualDraftCalculatesWeightVolumeAndVolumeWeightOnServer() { + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, "CKW-1", new BigDecimal("20"), + new BigDecimal("0.1"), "服装", 9L, List.of(item(new BigDecimal("3.25")))); + + InboundMeasurementEntity saved = service.saveBatch(command); + + assertThat(saved.getStatus()).isEqualTo("DRAFT"); + assertThat(saved.getTotalVolumeCbm()).isEqualByComparingTo("0.0480"); + assertThat(saved.getVolumeWeightKg()).isEqualByComparingTo("8.016"); + ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), items.capture()); + assertThat(items.getValue().get(0).getTotalWeightKg()).isEqualByComparingTo("6.500"); + } + + @Test + void staleDraftVersionIsRejectedBeforeReplacingItems() { + InboundMeasurementEntity existing = new InboundMeasurementEntity(); + existing.setId(100L); + existing.setTenantId(1L); + existing.setInboundId(10L); + existing.setSourceType("MANUAL"); + existing.setStatus("DRAFT"); + existing.setVersion(3); + when(repository.findMeasurement(1L, 10L, 100L)).thenReturn(existing); + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, 100L, 2, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, null, null, null, null, 9L, + List.of(item(BigDecimal.ONE))); + + assertThatThrownBy(() -> service.saveBatch(command)) + .isInstanceOf(BizException.class) + .hasMessageContaining("其他人修改"); + verify(repository, never()).replaceItems( + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), any()); + } + + private MeasurementBatchCommand.Item item(BigDecimal unitWeight) { + return new MeasurementBatchCommand.Item( + "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), + new BigDecimal("20"), unitWeight, 500L, null, + new BigDecimal("41"), new BigDecimal("31"), new BigDecimal("21"), "备注"); + } + + private InboundOrderEntity inbound() { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(10L); + inbound.setTenantId(1L); + inbound.setProcessStatus("WAIT_MEASURE"); + inbound.setVersion(0); + return inbound; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java new file mode 100644 index 000000000..c3d725a9f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java @@ -0,0 +1,162 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.config.InboundProperties; +import com.baiyue.firstleg.dto.ExceptionReleaseRequest; +import com.baiyue.firstleg.dto.MeasurementReviewRequest; +import com.baiyue.firstleg.entity.InboundMeasurementEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.repository.InboundWorkflowRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("入库测量审核服务") +class MeasurementReviewServiceTest { + @Test + @DisplayName("审核通过是测量到计费的唯一正常推进关口") + void shouldApproveOnlyPendingSubmittedMeasurement() { + Fixture fixture = fixture(false); + when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true)).thenReturn(1); + when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1); + + fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-1", + new MeasurementReviewRequest(20L, 3, null)); + + verify(fixture.repository).transitionReview(10001L, 10L, 9L, 3, true); + verify(fixture.repository).transitionMeasurement(10001L, 10L, 20L, "APPROVED"); + verify(fixture.repository).insertReview(any()); + verify(fixture.repository).insertHistory(any()); + } + + @Test + @DisplayName("驳回原因必填且状态推进为测量驳回") + void shouldRequireRejectReason() { + Fixture fixture = fixture(false); + assertThatThrownBy(() -> fixture.service.reject(10001L, 9L, "审核员", 10L, "reject-1", + new MeasurementReviewRequest(20L, 3, " "))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.VALIDATION_FAILED); + verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean()); + } + + @Test + @DisplayName("重复审核使用原幂等键时直接返回首次结果") + void shouldReplaySameReviewRequest() { + Fixture fixture = fixture(false); + when(fixture.idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) + .thenReturn(new InboundIdempotencyService.Context(1L, true)); + + fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-1", + new MeasurementReviewRequest(20L, 3, null)); + + verify(fixture.repository, never()).findOwned(anyLong(), anyLong()); + verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean()); + } + + @Test + @DisplayName("开关关闭时驳回单不能异常放行") + void shouldBlockExceptionReleaseByDefault() { + Fixture fixture = fixture(false); + assertThatThrownBy(() -> fixture.service.exceptionRelease(10001L, 9L, "主管", 10L, + "release-1", new ExceptionReleaseRequest(20L, 3, "客户确认紧急入库"))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(fixture.repository, never()).releaseRejected(anyLong(), anyLong(), anyLong(), anyInt()); + } + + @Test + @DisplayName("开关开启时仅放行已驳回单并保留驳回事实") + void shouldReleaseRejectedMeasurementWithHistory() { + Fixture fixture = fixture(true); + fixture.order.setProcessStatus("MEASURE_REJECTED"); + fixture.order.setMeasureReviewStatus("REJECTED"); + fixture.measurement.setStatus("REJECTED"); + when(fixture.repository.releaseRejected(10001L, 10L, 9L, 3)).thenReturn(1); + + fixture.service.exceptionRelease(10001L, 9L, "主管", 10L, "release-1", + new ExceptionReleaseRequest(20L, 3, "紧急放行")); + + verify(fixture.repository).releaseRejected(10001L, 10L, 9L, 3); + verify(fixture.repository, never()).transitionMeasurement(anyLong(), anyLong(), anyLong(), anyString()); + verify(fixture.repository).insertReview(any()); + } + + @Test + @DisplayName("并发审核只有一个条件更新成功") + void shouldAllowOnlyOneConcurrentReview() throws Exception { + Fixture fixture = fixture(false); + AtomicInteger attempts = new AtomicInteger(); + CountDownLatch bothArrived = new CountDownLatch(2); + when(fixture.repository.transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean())) + .thenAnswer(invocation -> { + bothArrived.countDown(); + bothArrived.await(); + return attempts.getAndIncrement() == 0 ? 1 : 0; + }); + when(fixture.repository.transitionMeasurement(anyLong(), anyLong(), anyLong(), anyString())).thenReturn(1); + var executor = Executors.newFixedThreadPool(2); + try { + Callable call = () -> { + try { + fixture.service.approve(10001L, 9L, "审核员", 10L, + "approve-" + Thread.currentThread().getId(), + new MeasurementReviewRequest(20L, 3, null)); + return "SUCCESS"; + } catch (BizException error) { + return error.getResultCode().name(); + } + }; + List> results = executor.invokeAll(List.of(call, call)); + assertThat(results.get(0).get() + "," + results.get(1).get()) + .contains("SUCCESS").contains("CONFLICT"); + assertThat(attempts).hasValue(2); + } finally { + executor.shutdownNow(); + } + } + + private Fixture fixture(boolean allowRelease) { + InboundWorkflowRepository repository = mock(InboundWorkflowRepository.class); + InboundIdempotencyService idempotency = mock(InboundIdempotencyService.class); + when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) + .thenReturn(new InboundIdempotencyService.Context(1L, false)); + InboundProperties properties = new InboundProperties(); + properties.setAllowRejectedMeasurement(allowRelease); + InboundOrderEntity order = new InboundOrderEntity(); + order.setProcessStatus("MEASURE_PENDING"); + order.setMeasureReviewStatus("PENDING"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setStatus("SUBMITTED"); + when(repository.findOwned(10001L, 10L)).thenReturn(order); + when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement); + return new Fixture(new MeasurementReviewService(repository, idempotency, properties), + repository, idempotency, order, measurement); + } + + private record Fixture(MeasurementReviewService service, InboundWorkflowRepository repository, + InboundIdempotencyService idempotency, InboundOrderEntity order, + InboundMeasurementEntity measurement) { + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java new file mode 100644 index 000000000..f9d2f0756 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java @@ -0,0 +1,52 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.dto.InboundMeasurementRequests; +import com.baiyue.firstleg.dto.InboundMeasurementResponses; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MeasurementSubmissionServiceTest { + private final InboundMeasurementService measurements = mock(InboundMeasurementService.class); + private final MeasurementLocationOccupationService locations = mock(MeasurementLocationOccupationService.class); + private final MeasurementSubmissionService service = new MeasurementSubmissionService(measurements, locations); + + @Test + void 测量提交与库位占用处于同一事务边界() throws Exception { + assertThat(MeasurementSubmissionService.class + .getDeclaredMethod("submit", long.class, long.class, long.class, + InboundMeasurementRequests.SubmitRequest.class) + .getAnnotation(Transactional.class)).isNotNull(); + } + + @Test + void 测量完成后立即占用库位再返回待客服审核() { + var request = new InboundMeasurementRequests.SubmitRequest(30L, 2); + var submitted = new InboundMeasurementResponses.MeasurementSubmitted( + 30L, 3, "MEASURE_PENDING", "PENDING", "STANDARD", null, null, null); + when(measurements.submit(10001L, 88L, 9L, request)).thenReturn(submitted); + + assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted); + + verify(locations).occupy(10001L, 88L, 9L, 30L, 3); + } + + @Test + void 库位占用失败向上抛出以触发测量事务回滚() { + var request = new InboundMeasurementRequests.SubmitRequest(30L, 2); + var submitted = new InboundMeasurementResponses.MeasurementSubmitted( + 30L, 3, "MEASURE_PENDING", "PENDING", "STANDARD", null, null, null); + when(measurements.submit(10001L, 88L, 9L, request)).thenReturn(submitted); + doThrow(new IllegalStateException("warehouse unavailable")) + .when(locations).occupy(10001L, 88L, 9L, 30L, 3); + + assertThatThrownBy(() -> service.submit(10001L, 88L, 9L, request)) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantInboundBillingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantInboundBillingServiceTest.java new file mode 100644 index 000000000..b69ae1d3f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantInboundBillingServiceTest.java @@ -0,0 +1,66 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; +import com.baiyue.firstleg.dto.InboundOrderDetail; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.repository.InboundOrderRepository; +import org.junit.jupiter.api.Test; + +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MerchantInboundBillingServiceTest { + private final InboundOrderRepository orders = mock(InboundOrderRepository.class); + private final FbaPricingService pricing = mock(FbaPricingService.class); + private final MerchantInboundBillingService service = new MerchantInboundBillingService(orders, pricing); + + @Test + void 客户只能看到本租户已完成入库且已发布的费用() { + InboundOrderEntity inbound = inbound("INBOUNDED", "SENT"); + InboundOrderDetail detail = mock(InboundOrderDetail.class); + FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class); + when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound); + when(orders.detail(10001L, inbound)).thenReturn(detail); + when(pricing.latest(10001L, 88L)).thenReturn(fee); + + var result = service.find(10001L, 77L); + + assertThat(result.inbound()).isSameAs(detail); + assertThat(result.latestFee()).isSameAs(fee); + verify(orders).findByOrderId(10001L, 77L); + } + + @Test + void 客服尚未完成发布时客户不可见中间费用() { + InboundOrderEntity inbound = inbound("FEE_CONFIRMING", "CONFIRMED"); + when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound); + + assertThatThrownBy(() -> service.find(10001L, 77L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("尚未由客服审核并发布"); + verify(pricing, never()).latest(10001L, 88L); + } + + @Test + void 使用租户条件查询防止跨租户读取订单费用() { + when(orders.findByOrderId(20002L, 77L)).thenReturn(null); + + assertThatThrownBy(() -> service.find(20002L, 77L)).isInstanceOf(BizException.class); + + verify(orders).findByOrderId(20002L, 77L); + verify(pricing, never()).latest(20002L, 88L); + } + + private static InboundOrderEntity inbound(String process, String feeStatus) { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(88L); + inbound.setProcessStatus(process); + inbound.setFeeStatus(feeStatus); + return inbound; + } +} diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index 9b99e0159..e32329506 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -69,7 +69,7 @@ spring: - id: firstleg-service uri: lb://firstleg-service predicates: - - Path=/api/merchant/v1/first-leg/**,/api/platform/v1/first-leg/** + - Path=/api/merchant/v1/first-leg/**,/api/platform/v1/first-leg/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/** # Swagger 文档聚合路由。 - id: auth-openapi uri: lb://auth-service @@ -135,7 +135,7 @@ baiyue: cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174,http://127.0.0.1:5173,http://127.0.0.1:5174} allowed-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS - allowed-headers: Authorization,Content-Type,Accept,X-Requested-With,X-Request-Id,Idempotency-Key + allowed-headers: Authorization,Content-Type,Accept,X-Requested-With,X-Request-Id,Idempotency-Key,X-App-Key,X-Timestamp,X-Nonce,X-Signature exposed-headers: X-Request-Id,X-Trace-Id,Location,Content-Disposition allow-credentials: ${CORS_ALLOW_CREDENTIALS:false} max-age: ${CORS_MAX_AGE:3600} @@ -146,6 +146,7 @@ baiyue: - /api/auth/login - /api/auth/register - /api/auth/refresh + - /openapi/v1/inbound-measurements/** - /actuator/health - /swagger-ui/** - /swagger-ui.html @@ -199,6 +200,8 @@ springdoc: url: /print-service/v3/api-docs/platform - name: firstleg-platform url: /firstleg-service/v3/api-docs/platform + - name: firstleg-measurement-openapi + url: /firstleg-service/v3/api-docs/measurement-openapi - name: audit url: /audit-service/v3/api-docs - name: integration diff --git a/gateway/src/test/java/com/baiyue/gateway/config/Phase2InboundRouteContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/Phase2InboundRouteContractTest.java new file mode 100644 index 000000000..d7662ec18 --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/Phase2InboundRouteContractTest.java @@ -0,0 +1,22 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class Phase2InboundRouteContractTest { + @Test + void 网关覆盖平台入库计费客户端费用和测量图片路径() throws Exception { + String yaml = new ClassPathResource("application.yml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(yaml) + .contains("/api/platform/v1/inbounds/**") + .contains("/api/platform/inbounds/**") + .contains("/api/platform/v1/fba-pricing/**") + .contains("/api/merchant/v1/first-leg/**") + .contains("/api/platform/v1/files/**"); + } +} diff --git a/iam/src/main/resources/db/phase2-fba-pricing-permissions.sql b/iam/src/main/resources/db/phase2-fba-pricing-permissions.sql new file mode 100644 index 000000000..e64162fbc --- /dev/null +++ b/iam/src/main/resources/db/phase2-fba-pricing-permissions.sql @@ -0,0 +1,29 @@ +-- 二期 FBA 计费权限;在入库查询、测量和审核权限脚本后执行,可重复部署。 +USE baiyue_iam; +SET NAMES utf8mb4; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted) +VALUES + (5102061, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:rule:view', '查看客户计费规则', NULL, NULL, 10, 'ACTIVE', 1, 0), + (5102062, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:rule:edit', '维护客户计费规则', NULL, NULL, 20, 'ACTIVE', 1, 0), + (5102063, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:rate:view', '查看承运商价格', NULL, NULL, 30, 'ACTIVE', 1, 0), + (5102064, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:rate:edit', '维护承运商价格', NULL, NULL, 40, 'ACTIVE', 1, 0), + (5102065, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:calculate', '计算入库费用', NULL, NULL, 50, 'ACTIVE', 1, 0), + (5102066, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:confirm', '内部确认入库费用', NULL, NULL, 60, 'ACTIVE', 1, 0), + (5102067, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:view', '查看入库费用', NULL, NULL, 70, 'ACTIVE', 1, 0), + (5102068, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:adjust', '调整已发布费用', NULL, NULL, 80, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), + status = 'ACTIVE', built_in = 1, deleted = 0; + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 FROM iam_permission +WHERE id BETWEEN 5102061 AND 5102068 +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000003, id, 0 FROM iam_permission +WHERE id IN (5102061, 5102063, 5102065, 5102066, 5102067, 5102068) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); diff --git a/iam/src/main/resources/db/phase2-inbound-measurement-permissions.sql b/iam/src/main/resources/db/phase2-inbound-measurement-permissions.sql new file mode 100644 index 000000000..7a7917c05 --- /dev/null +++ b/iam/src/main/resources/db/phase2-inbound-measurement-permissions.sql @@ -0,0 +1,32 @@ +-- 二期入库测量平台菜单和按钮权限,可重复执行。 +SET NAMES utf8mb4; +USE baiyue_iam; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, + route_path, http_method, sort_no, status, deleted) +VALUES + (5102041, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:measure:view', '查看入库测量', NULL, NULL, 10, 'ACTIVE', 0), + (5102042, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:measure:edit', '保存测量草稿', NULL, NULL, 10, 'ACTIVE', 0), + (5102043, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:measure:submit', '确认并提交测量', NULL, NULL, 20, 'ACTIVE', 0), + (5102044, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:weight:edit', '维护OCR重量', NULL, NULL, 30, 'ACTIVE', 0), + (5102045, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:weight:confirm', '确认OCR重量', NULL, NULL, 40, 'ACTIVE', 0) +ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), + permission_name = VALUES(permission_name), route_path = VALUES(route_path), + http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 FROM iam_permission +WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000003, id, 0 FROM iam_permission +WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); diff --git a/iam/src/main/resources/db/phase2-inbound-permissions.sql b/iam/src/main/resources/db/phase2-inbound-permissions.sql new file mode 100644 index 000000000..7eb5d33e3 --- /dev/null +++ b/iam/src/main/resources/db/phase2-inbound-permissions.sql @@ -0,0 +1,33 @@ +-- 二期入库审核与异常放行按钮权限,可重复部署。 +USE baiyue_iam; +SET NAMES utf8mb4; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, deleted) +VALUES + (5102051, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:measurement-review', + '测量审核', NULL, NULL, 10, 'ACTIVE', 0), + (5102052, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:measurement-review-history', + '查看测量审核历史', NULL, NULL, 20, 'ACTIVE', 0), + (5102053, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:exception-release', + '测量异常放行', NULL, NULL, 30, 'ACTIVE', 0), + (5102054, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:send-fee', + '发送费用并入库', NULL, NULL, 40, 'ACTIVE', 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), + status = 'ACTIVE', deleted = 0; + +-- 仅给平台超级管理员补齐默认权限;业务角色由 IAM 页面按职责授权。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, p.id, 0 +FROM iam_permission p +WHERE p.id BETWEEN 5102051 AND 5102054 +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- 客服(订单运营)负责测量审核;费用发送由审核通过后的服务端编排自动执行。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000003, p.id, 0 +FROM iam_permission p +WHERE p.id IN (5102051, 5102052) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); diff --git a/iam/src/main/resources/db/phase2-inbound-query-permissions.sql b/iam/src/main/resources/db/phase2-inbound-query-permissions.sql new file mode 100644 index 000000000..c56ed9c72 --- /dev/null +++ b/iam/src/main/resources/db/phase2-inbound-query-permissions.sql @@ -0,0 +1,25 @@ +-- 二期入库单列表和详情只读权限,可在一期 RBAC 脚本后重复执行。 +USE baiyue_iam; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted) +VALUES + (5102030, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:inbound:list', + '入库单', '/inbounds', 'GET', 30, 'ACTIVE', 1, 0), + (5102031, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:view', + '查看入库单详情', NULL, 'GET', 10, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), + route_path = VALUES(route_path), http_method = VALUES(http_method), + status = 'ACTIVE', built_in = 1, deleted = 0; + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT role.id, permission.id, role.tenant_id +FROM iam_role role +JOIN iam_permission permission + ON permission.tenant_id = 0 + AND permission.permission_code IN ( + 'platform:firstleg:inbound:list', 'platform:firstleg:inbound:view') +WHERE role.tenant_id = 0 + AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR'); diff --git a/iam/src/main/resources/db/phase2-location-permissions.sql b/iam/src/main/resources/db/phase2-location-permissions.sql new file mode 100644 index 000000000..7c7052594 --- /dev/null +++ b/iam/src/main/resources/db/phase2-location-permissions.sql @@ -0,0 +1,34 @@ +-- 二期库位管理菜单和按钮权限。 +-- 在 phase1-admin-rbac.sql、phase1-platform-master-data-and-permission-management.sql 之后执行,可重复执行。 +USE baiyue_iam; +SET NAMES utf8mb4; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, + route_path, http_method, sort_no, status, built_in, deleted) +VALUES + (5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:location:list', '库位管理', + '/master-data/locations', NULL, 40, 'ACTIVE', 1, 0), + (5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:location:create', '新增库位', + NULL, NULL, 10, 'ACTIVE', 1, 0), + (5103042, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:location:edit', '编辑库位', + NULL, NULL, 20, 'ACTIVE', 1, 0), + (5103043, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:location:status', '启停库位', + NULL, NULL, 30, 'ACTIVE', 1, 0), + (5103044, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:location:delete', '删除库位', + NULL, NULL, 40, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), + permission_name = VALUES(permission_name), route_path = VALUES(route_path), + sort_no = VALUES(sort_no), status = 'ACTIVE', built_in = 1, deleted = 0; + +-- 二期新增库位权限默认只授予平台系统管理员。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 +FROM iam_permission +WHERE tenant_id = 0 AND app_code = 'PLATFORM' + AND permission_code IN ( + 'platform:location:list', 'platform:location:create', 'platform:location:edit', + 'platform:location:status', 'platform:location:delete' + ) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); diff --git a/iam/src/main/resources/db/phase2-pallet-permissions.sql b/iam/src/main/resources/db/phase2-pallet-permissions.sql new file mode 100644 index 000000000..8794bb607 --- /dev/null +++ b/iam/src/main/resources/db/phase2-pallet-permissions.sql @@ -0,0 +1,39 @@ +-- 二期托盘管理菜单和按钮权限。 +-- 在 phase1-admin-rbac.sql、phase1-platform-master-data-and-permission-management.sql 之后执行,可重复执行。 +USE baiyue_iam; +SET NAMES utf8mb4; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, + route_path, http_method, sort_no, status, built_in, deleted) +VALUES + (5103050, 0, 'PLATFORM', 'MENU', 5103000, 'platform:pallet:list', '托盘管理', + '/master-data/pallets', NULL, 50, 'ACTIVE', 1, 0), + (5103051, 0, 'PLATFORM', 'BUTTON', 5103050, 'platform:pallet:create', '新增托盘', + NULL, NULL, 10, 'ACTIVE', 1, 0), + (5103052, 0, 'PLATFORM', 'BUTTON', 5103050, 'platform:pallet:edit', '编辑托盘', + NULL, NULL, 20, 'ACTIVE', 1, 0), + (5103054, 0, 'PLATFORM', 'BUTTON', 5103050, 'platform:pallet:delete', '删除托盘', + NULL, NULL, 30, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), + permission_name = VALUES(permission_name), route_path = VALUES(route_path), + sort_no = VALUES(sort_no), status = 'ACTIVE', built_in = 1, deleted = 0; + +-- 二期新增托盘权限默认只授予平台系统管理员。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 +FROM iam_permission +WHERE tenant_id = 0 AND app_code = 'PLATFORM' + AND permission_code IN ( + 'platform:pallet:list', 'platform:pallet:create', 'platform:pallet:edit', 'platform:pallet:delete' + ) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- 兼容已经试运行过旧脚本的环境:移除人工启停权限。 +DELETE rp FROM iam_role_permission rp +JOIN iam_permission p ON p.id = rp.permission_id +WHERE p.tenant_id = 0 AND p.app_code = 'PLATFORM' AND p.permission_code = 'platform:pallet:status'; +UPDATE iam_permission +SET status = 'INACTIVE', deleted = 1 +WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND permission_code = 'platform:pallet:status'; diff --git a/integration-service/src/main/java/com/baiyue/integration/controller/WeightOcrController.java b/integration-service/src/main/java/com/baiyue/integration/controller/WeightOcrController.java index eb89dfbdf..cfb4bd972 100644 --- a/integration-service/src/main/java/com/baiyue/integration/controller/WeightOcrController.java +++ b/integration-service/src/main/java/com/baiyue/integration/controller/WeightOcrController.java @@ -5,6 +5,8 @@ import com.baiyue.integration.dto.ocr.WeightOcrBatchRequest; import com.baiyue.integration.dto.ocr.WeightOcrBatchResult; import com.baiyue.integration.security.InternalServiceTokenGuard; import com.baiyue.integration.service.ocr.WeightOcrBatchService; +import com.baiyue.integration.service.ocr.WeightOcrSingleImageService; +import com.baiyue.integration.dto.ocr.WeightOcrImageResult; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; @@ -14,6 +16,9 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.http.MediaType; import java.io.IOException; @@ -27,6 +32,9 @@ public class WeightOcrController { @Resource private WeightOcrBatchService batchService; + @Resource + private WeightOcrSingleImageService singleImageService; + @Resource private InternalServiceTokenGuard tokenGuard; @@ -38,4 +46,13 @@ public class WeightOcrController { tokenGuard.verify(token); return ApiResponse.success(batchService.recognize(request)); } + + @Operation(summary = "识别单张称重图片") + @PostMapping(value = "/single", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse recognizeSingle( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestPart("file") MultipartFile file) throws IOException { + tokenGuard.verify(token); + return ApiResponse.success(singleImageService.recognize(file)); + } } diff --git a/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrSingleImageService.java b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrSingleImageService.java new file mode 100644 index 000000000..6e531e3d4 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrSingleImageService.java @@ -0,0 +1,75 @@ +package com.baiyue.integration.service.ocr; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.ocr.WeightOcrBatchRequest; +import com.baiyue.integration.dto.ocr.WeightOcrImageResult; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +@Service +public class WeightOcrSingleImageService { + private static final Set CONTENT_TYPES = Set.of("image/jpeg", "image/png", "image/bmp"); + private final IntegrationProperties properties; + private final WeightOcrBatchService batches; + + public WeightOcrSingleImageService(IntegrationProperties properties, WeightOcrBatchService batches) { + this.properties = properties; + this.batches = batches; + } + + public WeightOcrImageResult recognize(MultipartFile file) throws IOException { + IntegrationProperties.BaiduOcr config = properties.baiduOcr(); + if (config == null || config.inputRoot() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "OCR输入目录未配置"); + } + if (file == null || file.isEmpty()) { + throw new BizException(ResultCode.BAD_REQUEST, "称重图片不能为空"); + } + String contentType = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT); + if (!CONTENT_TYPES.contains(contentType)) { + throw new BizException(ResultCode.BAD_REQUEST, "称重图片仅支持 JPG、PNG、BMP"); + } + if (file.getSize() > config.effectiveMaxImageSize().toBytes()) { + throw new BizException(ResultCode.BAD_REQUEST, "称重图片超过允许大小"); + } + String extension = "image/png".equals(contentType) ? ".png" + : ("image/bmp".equals(contentType) ? ".bmp" : ".jpg"); + String requestId = UUID.randomUUID().toString(); + Path root = config.inputRoot().toAbsolutePath().normalize(); + Path requestDirectory = root.resolve("single").resolve(requestId).normalize(); + if (!requestDirectory.startsWith(root)) { + throw new BizException(ResultCode.FORBIDDEN, "OCR输入路径越界"); + } + Files.createDirectories(requestDirectory); + Path image = requestDirectory.resolve("weight" + extension); + try { + file.transferTo(image); + var batch = batches.recognize(new WeightOcrBatchRequest( + "single/" + requestId, "single-" + requestId, null, null, false)); + if (batch.results() == null || batch.results().size() != 1) { + throw new BizException(ResultCode.INTERNAL_ERROR, "OCR单图识别结果数量异常"); + } + return batch.results().get(0); + } finally { + deleteRequestDirectory(requestDirectory); + } + } + + private void deleteRequestDirectory(Path directory) { + try (var paths = Files.walk(directory)) { + paths.sorted(Comparator.reverseOrder()).forEach(path -> { + try { Files.deleteIfExists(path); } catch (IOException ignored) { } + }); + } catch (IOException ignored) { } + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java new file mode 100644 index 000000000..35262c053 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java @@ -0,0 +1,67 @@ +package com.baiyue.warehouse.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.warehouse.dto.LocationDtos; +import com.baiyue.warehouse.dto.PalletAllocationDtos; +import com.baiyue.warehouse.service.PalletLocationAllocationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.List; + +/** 头程入库 Saga 使用的托盘、库位内部接口。 */ +@RestController +@RequestMapping("/internal/warehouse/v1/pallet-locations") +@Tag(name = "托盘库位内部接口", description = "内部服务令牌保护的预占、确认和补偿释放") +public class InternalPalletLocationController { + private final PalletLocationAllocationService service; + private final byte[] expectedToken; + + public InternalPalletLocationController(PalletLocationAllocationService service, + @Value("${baiyue.security.internal-service-token}") String token) { + this.service = service; + this.expectedToken = token.getBytes(StandardCharsets.UTF_8); + } + + @PostMapping("/reservations") + @Operation(summary = "拆分托盘并预占一托一位") + public ApiResponse reserve(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody PalletAllocationDtos.ReserveRequest request) { + verify(token); + return ApiResponse.success(service.reserve(request)); + } + + @PostMapping("/confirm") + @Operation(summary = "确认托盘和库位占用", description = "只确认仓库资源,不推进头程入库单状态") + public ApiResponse confirm(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody PalletAllocationDtos.ConfirmRequest request) { + verify(token); + return ApiResponse.success(service.confirm(request)); + } + + @PostMapping("/release") + @Operation(summary = "释放未确认预占", description = "Saga补偿只释放RESERVED,已确认OCCUPIED不得反向释放") + public ApiResponse> release(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody PalletAllocationDtos.ReleaseRequest request) { + verify(token); + return ApiResponse.success(service.release(request)); + } + + private void verify(String token) { + byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(expectedToken, supplied)) { + throw new BizException(ResultCode.FORBIDDEN, "无效的内部服务凭据"); + } + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformLocationController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformLocationController.java new file mode 100644 index 000000000..1db483310 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformLocationController.java @@ -0,0 +1,178 @@ +package com.baiyue.warehouse.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.warehouse.dto.LocationDtos.Detail; +import com.baiyue.warehouse.dto.LocationDtos.PageQuery; +import com.baiyue.warehouse.dto.LocationDtos.SaveRequest; +import com.baiyue.warehouse.dto.LocationDtos.StatusRequest; +import com.baiyue.warehouse.entity.LocationEntity; +import com.baiyue.warehouse.service.LocationApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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; + +import java.util.List; + +/** 平台库位管理接口;当前数据模型中企业边界即目标 tenantId。 */ +@Validated +@RestController +@RequestMapping("/api/platform/v1/locations") +@Tag(name = "平台库位管理", description = "按企业租户和仓库数据范围管理库位基础资料") +public class PlatformLocationController { + private final LocationApplicationService service; + + public PlatformLocationController(LocationApplicationService service) { + this.service = service; + } + + @GetMapping + @PreAuthorize("hasAuthority('platform:location:list')") + @Operation(summary = "分页查询库位", description = "按目标企业租户、仓库、库区、编码、类型和状态分页查询") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:list 权限,且仓库须属于目标企业租户") + }) + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @Valid @ParameterObject PageQuery query) { + requirePlatform(user); + return ApiResponse.success(service.page(tenantId, query)); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAuthority('platform:location:list')") + @Operation(summary = "查询库位详情", description = "详情查询同时校验企业租户和仓库归属") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:list 权限或仓库超出数据范围"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "库位不存在") + }) + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @RequestParam @Min(1) long warehouseId, + @PathVariable long id) { + requirePlatform(user); + return ApiResponse.success(service.detail(tenantId, warehouseId, id)); + } + + @GetMapping("/available") + @PreAuthorize("hasAuthority('platform:location:list')") + @Operation(summary = "查询可用库位", description = "仅返回启用仓库中状态为 AVAILABLE、未预占且未占用的库位") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:list 权限或仓库超出数据范围") + }) + public ApiResponse> available(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @RequestParam @Min(1) long warehouseId, + @RequestParam(defaultValue = "20") @Min(1) @Max(200) int limit) { + requirePlatform(user); + return ApiResponse.success(service.findAvailable(tenantId, warehouseId, limit)); + } + + @PostMapping + @PreAuthorize("hasAuthority('platform:location:create')") + @Operation(summary = "新增库位", description = "在目标企业租户的指定仓库内新增唯一编码库位") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "新增成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:create 权限或仓库超出数据范围"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "库位编码重复") + }) + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @Valid @RequestBody SaveRequest request) { + requirePlatform(user); + return ApiResponse.success(service.create(tenantId, request)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAuthority('platform:location:edit')") + @Operation(summary = "编辑库位", description = "按 version 乐观锁编辑;不能跨仓库移动或绕过启停接口修改状态") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "编辑成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:edit 权限或数据范围校验失败"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "版本冲突、编码重复或库位正在被使用") + }) + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @RequestParam @Min(1) long warehouseId, + @PathVariable long id, + @Valid @RequestBody SaveRequest request) { + requirePlatform(user); + service.update(tenantId, warehouseId, id, request); + return ApiResponse.success(null); + } + + @PatchMapping("/{id}/status") + @PreAuthorize("hasAuthority('platform:location:status')") + @Operation(summary = "启用或停用库位", description = "按 version 条件更新;活动预占或占用中的库位不能停用") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "状态变更成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:status 权限或仓库超出数据范围"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "版本冲突或库位正在被使用") + }) + public ApiResponse changeStatus(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @RequestParam @Min(1) long warehouseId, + @PathVariable long id, + @Valid @RequestBody StatusRequest request) { + requirePlatform(user); + service.changeStatus(tenantId, warehouseId, id, request.status(), request.version(), requireUserId(user)); + return ApiResponse.success(null); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAuthority('platform:location:delete')") + @Operation(summary = "删除库位", description = "仅允许按 version 逻辑删除已停用且无活动预占/占用的库位") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:delete 权限或仓库超出数据范围"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "库位未停用、正在被使用或版本冲突") + }) + public ApiResponse delete(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @RequestParam @Min(1) long warehouseId, + @PathVariable long id, + @Parameter(description = "乐观锁版本号", required = true) + @RequestParam @Min(0) int version) { + requirePlatform(user); + service.delete(tenantId, warehouseId, id, version, requireUserId(user)); + return ApiResponse.success(null); + } + + private static void requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equals(user.appCode())) { + throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); + } + } + + private static long requireUserId(AuthenticatedUser user) { + if (user.userId() == null || user.userId() <= 0) { + throw new BizException(ResultCode.UNAUTHORIZED, "登录用户标识缺失"); + } + return user.userId(); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java new file mode 100644 index 000000000..456e0bd66 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java @@ -0,0 +1,103 @@ +package com.baiyue.warehouse.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.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.service.PalletApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.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.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; + +/** 平台托盘管理接口。 */ +@Validated +@RestController +@RequestMapping("/api/platform/v1/pallets") +@Tag(name = "平台托盘管理", description = "按租户和仓库维护托盘主数据") +public class PlatformPalletController { + private final PalletApplicationService service; + + public PlatformPalletController(PalletApplicationService service) { + this.service = service; + } + + @GetMapping + @PreAuthorize("hasAuthority('platform:pallet:list')") + @Operation(summary = "分页查询托盘") + @ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:pallet:list 权限")) + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @Valid @ParameterObject PalletRequests.PageQuery query) { + requirePlatform(user); + return ApiResponse.success(service.page(tenantId, query)); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAuthority('platform:pallet:list')") + @Operation(summary = "查询托盘详情") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @RequestParam @Min(1) long warehouseId, + @PathVariable long id) { + requirePlatform(user); + return ApiResponse.success(service.detail(tenantId, warehouseId, id)); + } + + @PostMapping + @PreAuthorize("hasAuthority('platform:pallet:create')") + @Operation(summary = "新增托盘") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @Valid @RequestBody PalletRequests.SaveRequest request) { + requirePlatform(user); + return ApiResponse.success(service.create(tenantId, request)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAuthority('platform:pallet:edit')") + @Operation(summary = "编辑托盘") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @RequestParam @Min(1) long warehouseId, + @PathVariable long id, + @Valid @RequestBody PalletRequests.SaveRequest request) { + requirePlatform(user); + service.update(tenantId, warehouseId, id, request); + return ApiResponse.success(null); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAuthority('platform:pallet:delete')") + @Operation(summary = "删除托盘", description = "仅可删除未预占、未占用的托盘") + public ApiResponse delete(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @RequestParam @Min(1) long warehouseId, + @PathVariable long id) { + requirePlatform(user); + service.delete(tenantId, warehouseId, id); + return ApiResponse.success(null); + } + + private static void requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equals(user.appCode())) { + throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); + } + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationDtos.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationDtos.java new file mode 100644 index 000000000..f5a5ec924 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationDtos.java @@ -0,0 +1,153 @@ +package com.baiyue.warehouse.dto; + +import com.baiyue.warehouse.entity.LocationEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +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.LocalDateTime; +import java.util.List; + +/** 库位管理及任务7资源编排共用 DTO。 */ +public final class LocationDtos { + private LocationDtos() { + } + + @Schema(name = "LocationPageQuery", description = "库位分页查询条件") + public record PageQuery( + @Min(1) long page, + @Min(1) @Max(200) long size, + @NotNull @Min(1) Long warehouseId, + @Size(max = 50) String zoneCode, + @Size(max = 50) String locationCode, + @Pattern(regexp = "^(SHELF|PALLET|FLOOR|FLOW_RACK)$") String locationType, + @Pattern(regexp = "^(AVAILABLE|RESERVED|OCCUPIED|DISABLED)$") String status + ) { + public PageQuery { + if (page == 0) { + page = 1; + } + if (size == 0) { + size = 20; + } + } + } + + @Schema(name = "LocationSaveRequest", description = "库位新增或编辑请求") + public record SaveRequest( + @NotNull @Min(1) Long warehouseId, + @NotBlank @Size(max = 50) String zoneCode, + @NotBlank @Size(max = 100) String zoneName, + @NotBlank @Size(max = 50) String locationCode, + @Size(max = 100) String locationName, + @NotBlank @Pattern(regexp = "^(SHELF|PALLET|FLOOR|FLOW_RACK)$") String locationType, + @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg, + @NotBlank @Pattern(regexp = "^(AVAILABLE|DISABLED)$") String status, + @Size(max = 500) String remark, + @Min(0) Integer version + ) { + } + + @Schema(name = "LocationStatusRequest", description = "库位启停请求") + public record StatusRequest( + @NotBlank @Pattern(regexp = "^(AVAILABLE|DISABLED)$") String status, + @NotNull @Min(0) Integer version + ) { + } + + @Schema(name = "LocationDetail", description = "带仓库名称的库位详情") + public record Detail(LocationEntity location, String warehouseCode, String warehouseName) { + } + + /** 单个托盘的幂等预占目标。 */ + public record ReservationTarget( + @NotNull @Min(1) Long palletId, + @NotBlank @Size(max = 50) String palletCode, + @NotBlank @Size(max = 128) String idempotencyKey + ) { + } + + /** 原子选择并预占 N 个可用库位的命令。 */ + public record ReserveCommand( + @Min(1) long tenantId, + @Min(1) long warehouseId, + @NotBlank @Size(max = 40) String sourceService, + @Min(1) long businessId, + @NotBlank @Size(max = 40) String inboundCode, + @Min(1) long operatorId, + @NotNull LocalDateTime expiresAt, + @NotEmpty @Size(max = 200) List<@Valid ReservationTarget> targets + ) { + } + + public record ReservationResult( + long reservationId, + String reservationNo, + long palletId, + String palletCode, + long locationId, + String locationCode, + boolean sharedOverflow, + LocalDateTime expiresAt + ) { + } + + /** 确认占用时补齐本托盘分配汇总。 */ + public record OccupancyTarget( + @Min(1) long reservationId, + @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal allocatedVolumeCbm, + @Min(1) Integer allocatedPackageCount + ) { + } + + public record ConfirmCommand( + @Min(1) long tenantId, + @Min(1) long warehouseId, + @NotBlank @Size(max = 40) String sourceService, + @Min(1) long businessId, + @Min(1) long operatorId, + @NotEmpty @Size(max = 200) List<@Valid OccupancyTarget> targets + ) { + } + + public record OccupancyResult( + long occupancyId, + String occupancyNo, + long reservationId, + long palletId, + long locationId, + String locationCode, + boolean sharedOverflow + ) { + } + + public record ReleaseCommand( + @Min(1) long tenantId, + @NotBlank @Size(max = 40) String sourceService, + @Min(1) long businessId, + @Min(1) long operatorId, + @NotBlank @Size(max = 500) String reason + ) { + } + + public record ActiveResource( + long reservationId, + Long occupancyId, + long warehouseId, + long palletId, + String palletCode, + long locationId, + String locationCode, + boolean sharedOverflow, + String state + ) { + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletAllocationDtos.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletAllocationDtos.java new file mode 100644 index 000000000..798417feb --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletAllocationDtos.java @@ -0,0 +1,70 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** 头程调用仓库托盘、库位编排的内部契约。 */ +public final class PalletAllocationDtos { + private PalletAllocationDtos() { + } + + public record ReserveRequest( + @Min(1) long tenantId, + @Min(1) long warehouseId, + @NotBlank @Size(max = 40) String sourceService, + @Min(1) long businessId, + @NotBlank @Size(max = 40) String inboundCode, + @NotBlank @Size(max = 128) String idempotencyKey, + @Min(1) long operatorId, + @NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6) BigDecimal totalVolumeCbm, + @NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6) BigDecimal palletCapacityCbm, + @Size(max = 32) String palletType, + @NotNull LocalDateTime expiresAt, + @NotNull Boolean measurementCompleted + ) { + } + + public record ConfirmRequest( + @Min(1) long tenantId, + @Min(1) long warehouseId, + @NotBlank @Size(max = 40) String sourceService, + @Min(1) long businessId, + @Min(1) long operatorId, + @NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6) BigDecimal totalVolumeCbm, + @NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6) BigDecimal palletCapacityCbm + ) { + } + + public record ReleaseRequest( + @Min(1) long tenantId, + @NotBlank @Size(max = 40) String sourceService, + @Min(1) long businessId, + @Min(1) long operatorId, + @NotBlank @Size(max = 500) String reason + ) { + } + + public record AllocationResult( + long reservationId, + String reservationNo, + int allocationNo, + long palletId, + String palletCode, + long locationId, + String locationCode, + BigDecimal allocatedVolumeCbm, + String state + ) { + } + + public record BatchResult(int palletCount, List allocations) { + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java new file mode 100644 index 000000000..68fd6142c --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java @@ -0,0 +1,72 @@ +package com.baiyue.warehouse.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** 托盘管理请求契约。 */ +public final class PalletRequests { + private PalletRequests() { + } + + @Schema(name = "PalletSaveRequest", description = "托盘新增或编辑请求") + public record SaveRequest( + @NotNull @Min(1) + @Schema(description = "所属仓库ID", example = "190000000000000002", requiredMode = Schema.RequiredMode.REQUIRED) + Long warehouseId, + @NotBlank @Size(max = 50) + @Schema(description = "仓库内唯一托盘编码", example = "TP-001", requiredMode = Schema.RequiredMode.REQUIRED) + String palletCode, + @NotBlank @Size(max = 32) + @Schema(description = "托盘类型", example = "木托盘", requiredMode = Schema.RequiredMode.REQUIRED) + String palletType, + @NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6) + @Schema(description = "托盘容积,单位m³", example = "1.200000", requiredMode = Schema.RequiredMode.REQUIRED) + BigDecimal capacityCbm, + @Size(max = 500) + @Schema(description = "备注", example = "标准川字托盘") + String remark + ) { + } + + @Schema(name = "PalletPageQuery", description = "托盘分页查询条件") + public record PageQuery( + @Min(1) @Schema(description = "页码", defaultValue = "1") long page, + @Min(1) @Max(200) @Schema(description = "每页条数", defaultValue = "20") long size, + @Min(1) @Schema(description = "仓库ID") Long warehouseId, + @Size(max = 50) @Schema(description = "托盘编码,支持模糊查询") String palletCode, + @Size(max = 32) @Schema(description = "托盘类型") String palletType + ) { + public PageQuery { + if (page == 0) { + page = 1; + } + if (size == 0) { + size = 20; + } + } + } + + /** 平台托盘视图不暴露内部预占、占用状态。 */ + @Schema(name = "PalletView", description = "托盘主数据视图") + public record View( + Long id, + Long tenantId, + Long warehouseId, + String palletCode, + String palletType, + BigDecimal capacityCbm, + String remark, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/LocationEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/LocationEntity.java new file mode 100644 index 000000000..830e65844 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/LocationEntity.java @@ -0,0 +1,51 @@ +package com.baiyue.warehouse.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baiyue.common.core.model.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; + +/** 库位基础资料实体,对应二期共用表 {@code wh_location}。 */ +@Getter +@Setter +@TableName("wh_location") +@Schema(name = "Location", description = "库位基础资料") +public class LocationEntity extends BaseEntity { + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "库位 ID", example = "190000000000000001") + private Long id; + @Schema(description = "所属企业租户 ID", example = "10001") + private Long tenantId; + @Schema(description = "所属仓库 ID", example = "190000000000000101") + private Long warehouseId; + @TableField(exist = false) + @Schema(description = "仓库编码", example = "US-LAX-01") + private String warehouseCode; + @TableField(exist = false) + @Schema(description = "仓库名称", example = "洛杉矶长滩海外仓") + private String warehouseName; + @Schema(description = "库区编码", example = "NORMAL") + private String zoneCode; + @Schema(description = "库区名称", example = "常规货") + private String zoneName; + @Schema(description = "租户仓库内唯一库位编码", example = "A1-01") + private String locationCode; + @Schema(description = "库位名称", example = "A1 区 01 位") + private String locationName; + @Schema(description = "库位类型", allowableValues = {"SHELF", "PALLET", "FLOOR", "FLOW_RACK"}) + private String locationType; + @Schema(description = "最大承重 kg,仅展示,不参与二期容量拦截", example = "500.000") + private BigDecimal maxWeightKg; + @Schema(description = "是否为系统共享溢出位 A0", example = "false") + private Boolean sharedOverflow; + @Schema(description = "资源状态", allowableValues = {"AVAILABLE", "RESERVED", "OCCUPIED", "DISABLED"}) + private String status; + @Schema(description = "备注", example = "靠近入库口") + private String remark; +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/LocationOccupancyEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/LocationOccupancyEntity.java new file mode 100644 index 000000000..9334e0a66 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/LocationOccupancyEntity.java @@ -0,0 +1,41 @@ +package com.baiyue.warehouse.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; + +/** 库位占用事实实体,仅由 {@code LocationResourceService} 维护。 */ +@Getter +@Setter +@TableName("wh_location_occupancy") +public class LocationOccupancyEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long tenantId; + private String occupancyNo; + private Long reservationId; + private String sourceService; + private Long businessId; + private String inboundCode; + private Long warehouseId; + private Long palletId; + private String palletCode; + private Long locationId; + private String locationCode; + private Boolean sharedOverflow; + private BigDecimal allocatedVolumeCbm; + private Integer allocatedPackageCount; + private String occupancyStatus; + private LocalDateTime occupiedAt; + private LocalDateTime releasedAt; + private String releaseReason; + private Long createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Integer version; +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/LocationReservationEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/LocationReservationEntity.java new file mode 100644 index 000000000..e447d495a --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/LocationReservationEntity.java @@ -0,0 +1,39 @@ +package com.baiyue.warehouse.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; + +/** 库位活动预占实体,仅由 {@code LocationResourceService} 维护。 */ +@Getter +@Setter +@TableName("wh_location_reservation") +public class LocationReservationEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long tenantId; + private String reservationNo; + private String idempotencyKey; + private String sourceService; + private Long businessId; + private String inboundCode; + private Long warehouseId; + private Long palletId; + private String palletCode; + private Long locationId; + private String locationCode; + private Boolean sharedOverflow; + private String reservationStatus; + private LocalDateTime expiresAt; + private LocalDateTime confirmedAt; + private LocalDateTime releasedAt; + private String releaseReason; + private Long createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Integer version; +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/PalletEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/PalletEntity.java new file mode 100644 index 000000000..2edf443bd --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/PalletEntity.java @@ -0,0 +1,45 @@ +package com.baiyue.warehouse.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 io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; + +/** + * 托盘主数据实体。状态同时承载可用、预占、占用和停用四种资源状态。 + */ +@Getter +@Setter +@TableName("wh_pallet") +@Schema(name = "Pallet", description = "托盘主数据") +public class PalletEntity extends BaseEntity { + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "托盘ID", example = "190000000000000001") + private Long id; + + @Schema(description = "所属租户ID", example = "10001") + private Long tenantId; + + @Schema(description = "所属仓库ID", example = "190000000000000002") + private Long warehouseId; + + @Schema(description = "托盘编码", example = "TP-001") + private String palletCode; + + @Schema(description = "托盘类型", example = "木托盘") + private String palletType; + + @Schema(description = "托盘容积,单位m³", example = "1.200000") + private BigDecimal capacityCbm; + + @Schema(description = "资源状态", allowableValues = {"AVAILABLE", "RESERVED", "OCCUPIED", "DISABLED"}) + private String status; + + @Schema(description = "备注", example = "标准川字托盘") + private String remark; +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationMapper.java new file mode 100644 index 000000000..6d1e13851 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationMapper.java @@ -0,0 +1,162 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.warehouse.entity.LocationEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +/** 库位基础资料及并发状态条件更新 Mapper。 */ +@Mapper +public interface LocationMapper extends BaseMapper { + @Select(""" + + """) + Page selectLocationPage(Page page, + @Param("tenantId") long tenantId, + @Param("warehouseId") long warehouseId, + @Param("zoneCode") String zoneCode, + @Param("locationCode") String locationCode, + @Param("locationType") String locationType, + @Param("status") String status); + + @Select(""" + SELECT l.*, w.warehouse_code, w.warehouse_name + FROM wh_location l + JOIN wh_warehouse w + ON w.id = l.warehouse_id AND w.tenant_id = l.tenant_id + AND w.status = 'ACTIVE' AND w.deleted = 0 + WHERE l.tenant_id = #{tenantId} + AND l.warehouse_id = #{warehouseId} + AND l.status = 'AVAILABLE' AND l.shared_overflow = 0 AND l.deleted = 0 + AND NOT EXISTS ( + SELECT 1 FROM wh_location_reservation r + WHERE r.tenant_id = l.tenant_id AND r.location_id = l.id + AND r.reservation_status = 'RESERVED' + ) + AND NOT EXISTS ( + SELECT 1 FROM wh_location_occupancy o + WHERE o.tenant_id = l.tenant_id AND o.location_id = l.id + AND o.occupancy_status = 'OCCUPIED' + ) + ORDER BY l.zone_code, + REGEXP_REPLACE(l.location_code, '[0-9]+$', ''), + CAST(COALESCE(REGEXP_SUBSTR(l.location_code, '[0-9]+$'), '0') AS UNSIGNED), + l.location_code, l.id + LIMIT #{limit} + """) + List selectAvailable(@Param("tenantId") long tenantId, + @Param("warehouseId") long warehouseId, + @Param("limit") int limit); + + @Select(""" + SELECT l.* + FROM wh_location l + JOIN wh_warehouse w + ON w.id = l.warehouse_id AND w.tenant_id = l.tenant_id + AND w.status = 'ACTIVE' AND w.deleted = 0 + WHERE l.tenant_id = #{tenantId} + AND l.warehouse_id = #{warehouseId} + AND l.status = 'AVAILABLE' AND l.shared_overflow = 0 AND l.deleted = 0 + AND NOT EXISTS ( + SELECT 1 FROM wh_location_reservation r + WHERE r.tenant_id = l.tenant_id AND r.location_id = l.id + AND r.reservation_status = 'RESERVED' + ) + AND NOT EXISTS ( + SELECT 1 FROM wh_location_occupancy o + WHERE o.tenant_id = l.tenant_id AND o.location_id = l.id + AND o.occupancy_status = 'OCCUPIED' + ) + ORDER BY l.zone_code, + REGEXP_REPLACE(l.location_code, '[0-9]+$', ''), + CAST(COALESCE(REGEXP_SUBSTR(l.location_code, '[0-9]+$'), '0') AS UNSIGNED), + l.location_code, l.id + LIMIT #{limit} + FOR UPDATE SKIP LOCKED + """) + List lockAvailable(@Param("tenantId") long tenantId, + @Param("warehouseId") long warehouseId, + @Param("limit") int limit); + + @Insert(""" + INSERT INTO wh_location ( + id, tenant_id, warehouse_id, zone_code, zone_name, location_code, location_name, + location_type, max_weight_kg, shared_overflow, status, remark, + created_by, created_at, updated_by, updated_at, deleted, version + ) VALUES ( + #{id}, #{tenantId}, #{warehouseId}, 'OVERFLOW', '共享溢出区', 'A0', '默认共享溢出位', + 'FLOOR', NULL, 1, 'AVAILABLE', '系统自动创建;普通库位不足时共享使用', + #{operatorId}, CURRENT_TIMESTAMP(3), #{operatorId}, CURRENT_TIMESTAMP(3), 0, 0 + ) + ON DUPLICATE KEY UPDATE id = id + """) + int insertSharedOverflowIfAbsent(@Param("id") long id, + @Param("tenantId") long tenantId, + @Param("warehouseId") long warehouseId, + @Param("operatorId") long operatorId); + + @Select(""" + SELECT * FROM wh_location + WHERE tenant_id = #{tenantId} AND warehouse_id = #{warehouseId} + AND location_code = 'A0' AND shared_overflow = 1 + AND status = 'AVAILABLE' AND deleted = 0 + FOR UPDATE + """) + LocationEntity lockSharedOverflow(@Param("tenantId") long tenantId, + @Param("warehouseId") long warehouseId); + + @Update(""" + UPDATE wh_location + SET status = #{targetStatus}, version = version + 1, + updated_by = #{operatorId}, updated_at = CURRENT_TIMESTAMP(3) + WHERE id = #{id} AND tenant_id = #{tenantId} AND warehouse_id = #{warehouseId} + AND status = #{expectedStatus} AND version = #{version} AND deleted = 0 + """) + int updateStatusConditionally(@Param("tenantId") long tenantId, + @Param("warehouseId") long warehouseId, + @Param("id") long id, + @Param("expectedStatus") String expectedStatus, + @Param("targetStatus") String targetStatus, + @Param("version") int version, + @Param("operatorId") long operatorId); + + @Update(""" + UPDATE wh_location + SET deleted = 1, version = version + 1, + updated_by = #{operatorId}, updated_at = CURRENT_TIMESTAMP(3) + WHERE id = #{id} AND tenant_id = #{tenantId} AND warehouse_id = #{warehouseId} + AND status = 'DISABLED' AND version = #{version} AND deleted = 0 + """) + int deleteDisabledConditionally(@Param("tenantId") long tenantId, + @Param("warehouseId") long warehouseId, + @Param("id") long id, + @Param("version") int version, + @Param("operatorId") long operatorId); +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationOccupancyMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationOccupancyMapper.java new file mode 100644 index 000000000..8c3e60595 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationOccupancyMapper.java @@ -0,0 +1,29 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.LocationOccupancyEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +/** 库位占用事实 Mapper。 */ +@Mapper +public interface LocationOccupancyMapper extends BaseMapper { + @Select(""" + SELECT COUNT(1) FROM wh_location_occupancy + WHERE tenant_id = #{tenantId} AND location_id = #{locationId} + AND occupancy_status = 'OCCUPIED' + """) + int countActiveByLocation(@Param("tenantId") long tenantId, @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_location_occupancy + SET occupancy_status = 'RELEASED', released_at = CURRENT_TIMESTAMP(3), + release_reason = #{reason}, updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{id} AND tenant_id = #{tenantId} + AND occupancy_status = 'OCCUPIED' AND version = #{version} + """) + int release(@Param("tenantId") long tenantId, @Param("id") long id, + @Param("version") int version, @Param("reason") String reason); +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationReservationMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationReservationMapper.java new file mode 100644 index 000000000..ca891b504 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationReservationMapper.java @@ -0,0 +1,45 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.LocationReservationEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +/** 库位预占 Mapper。 */ +@Mapper +public interface LocationReservationMapper extends BaseMapper { + @Select(""" + SELECT COUNT(1) FROM wh_location_reservation + WHERE tenant_id = #{tenantId} AND location_id = #{locationId} + AND reservation_status = 'RESERVED' + """) + int countActiveByLocation(@Param("tenantId") long tenantId, @Param("locationId") long locationId); + + @Select(""" + SELECT * FROM wh_location_reservation + WHERE id = #{id} AND tenant_id = #{tenantId} + FOR UPDATE + """) + LocationReservationEntity lockById(@Param("tenantId") long tenantId, @Param("id") long id); + + @Update(""" + UPDATE wh_location_reservation + SET reservation_status = 'CONFIRMED', confirmed_at = CURRENT_TIMESTAMP(3), + updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{id} AND tenant_id = #{tenantId} + AND reservation_status = 'RESERVED' AND version = #{version} + """) + int confirm(@Param("tenantId") long tenantId, @Param("id") long id, @Param("version") int version); + + @Update(""" + UPDATE wh_location_reservation + SET reservation_status = 'RELEASED', released_at = CURRENT_TIMESTAMP(3), + release_reason = #{reason}, updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{id} AND tenant_id = #{tenantId} + AND reservation_status = 'RESERVED' AND version = #{version} + """) + int release(@Param("tenantId") long tenantId, @Param("id") long id, + @Param("version") int version, @Param("reason") String reason); +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletMapper.java new file mode 100644 index 000000000..693c8b691 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.PalletEntity; + +/** 托盘主数据 Mapper。 */ +public interface PalletMapper extends BaseMapper { +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/LocationRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/LocationRepository.java new file mode 100644 index 000000000..22c169d62 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/LocationRepository.java @@ -0,0 +1,156 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.warehouse.dto.LocationDtos.PageQuery; +import com.baiyue.warehouse.entity.LocationEntity; +import com.baiyue.warehouse.entity.LocationOccupancyEntity; +import com.baiyue.warehouse.entity.LocationReservationEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.mapper.LocationMapper; +import com.baiyue.warehouse.mapper.LocationOccupancyMapper; +import com.baiyue.warehouse.mapper.LocationReservationMapper; +import com.baiyue.warehouse.mapper.WarehouseMapper; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; + +/** 强制使用租户、仓库联合条件的库位持久化边界。 */ +@Repository +public class LocationRepository { + private final LocationMapper locations; + private final LocationReservationMapper reservations; + private final LocationOccupancyMapper occupancies; + private final WarehouseMapper warehouses; + + public LocationRepository(LocationMapper locations, LocationReservationMapper reservations, + LocationOccupancyMapper occupancies, WarehouseMapper warehouses) { + this.locations = locations; + this.reservations = reservations; + this.occupancies = occupancies; + this.warehouses = warehouses; + } + + public WarehouseEntity findWarehouse(long tenantId, long warehouseId) { + return warehouses.selectOne(new LambdaQueryWrapper() + .eq(WarehouseEntity::getTenantId, tenantId) + .eq(WarehouseEntity::getId, warehouseId)); + } + + public LocationEntity findOwned(long tenantId, long warehouseId, long id) { + return locations.selectOne(new LambdaQueryWrapper() + .eq(LocationEntity::getTenantId, tenantId) + .eq(LocationEntity::getWarehouseId, warehouseId) + .eq(LocationEntity::getId, id)); + } + + public PageResult page(long tenantId, PageQuery query) { + Page result = locations.selectLocationPage(new Page<>(query.page(), query.size()), + tenantId, query.warehouseId(), query.zoneCode(), query.locationCode(), query.locationType(), query.status()); + return PageResult.from(result); + } + + public boolean existsCode(long tenantId, long warehouseId, String code, Long excludedId) { + return locations.selectCount(new LambdaQueryWrapper() + .eq(LocationEntity::getTenantId, tenantId) + .eq(LocationEntity::getWarehouseId, warehouseId) + .eq(LocationEntity::getLocationCode, code) + .ne(excludedId != null, LocationEntity::getId, excludedId)) > 0; + } + + public void insert(LocationEntity entity) { + locations.insert(entity); + } + + public int save(LocationEntity entity) { + return locations.updateById(entity); + } + + public List findAvailable(long tenantId, long warehouseId, int limit) { + return locations.selectAvailable(tenantId, warehouseId, limit); + } + + public List lockAvailable(long tenantId, long warehouseId, int limit) { + return locations.lockAvailable(tenantId, warehouseId, limit); + } + + /** 依靠租户仓库编码唯一键并发创建或复用系统共享 A0。 */ + public LocationEntity lockOrCreateSharedOverflow(long tenantId, long warehouseId, long operatorId) { + locations.insertSharedOverflowIfAbsent(IdWorker.getId(), tenantId, warehouseId, operatorId); + return locations.lockSharedOverflow(tenantId, warehouseId); + } + + public int changeStatus(long tenantId, long warehouseId, long id, String expected, String target, + int version, long operatorId) { + return locations.updateStatusConditionally(tenantId, warehouseId, id, expected, target, version, operatorId); + } + + public int deleteDisabled(long tenantId, long warehouseId, long id, int version, long operatorId) { + return locations.deleteDisabledConditionally(tenantId, warehouseId, id, version, operatorId); + } + + public boolean hasActiveResource(long tenantId, long locationId) { + return reservations.countActiveByLocation(tenantId, locationId) > 0 + || occupancies.countActiveByLocation(tenantId, locationId) > 0; + } + + public List findReservationsByKeys(long tenantId, String sourceService, + Collection keys) { + if (keys.isEmpty()) { + return List.of(); + } + return reservations.selectList(new LambdaQueryWrapper() + .eq(LocationReservationEntity::getTenantId, tenantId) + .eq(LocationReservationEntity::getSourceService, sourceService) + .in(LocationReservationEntity::getIdempotencyKey, keys)); + } + + public List findReservationsByBusiness(long tenantId, String sourceService, + long businessId) { + return reservations.selectList(new LambdaQueryWrapper() + .eq(LocationReservationEntity::getTenantId, tenantId) + .eq(LocationReservationEntity::getSourceService, sourceService) + .eq(LocationReservationEntity::getBusinessId, businessId)); + } + + public LocationReservationEntity lockReservation(long tenantId, long id) { + return reservations.lockById(tenantId, id); + } + + public void insertReservation(LocationReservationEntity entity) { + reservations.insert(entity); + } + + public int confirmReservation(LocationReservationEntity entity) { + return reservations.confirm(entity.getTenantId(), entity.getId(), entity.getVersion()); + } + + public int releaseReservation(LocationReservationEntity entity, String reason) { + return reservations.release(entity.getTenantId(), entity.getId(), entity.getVersion(), reason); + } + + public List findOccupanciesByBusiness(long tenantId, String sourceService, + long businessId) { + return occupancies.selectList(new LambdaQueryWrapper() + .eq(LocationOccupancyEntity::getTenantId, tenantId) + .eq(LocationOccupancyEntity::getSourceService, sourceService) + .eq(LocationOccupancyEntity::getBusinessId, businessId)); + } + + public LocationOccupancyEntity findOccupancyByReservation(long tenantId, long reservationId) { + return occupancies.selectOne(new LambdaQueryWrapper() + .eq(LocationOccupancyEntity::getTenantId, tenantId) + .eq(LocationOccupancyEntity::getReservationId, reservationId)); + } + + public void insertOccupancy(LocationOccupancyEntity entity) { + occupancies.insert(entity); + } + + public int releaseOccupancy(LocationOccupancyEntity entity, String reason) { + return occupancies.release(entity.getTenantId(), entity.getId(), entity.getVersion(), reason); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java new file mode 100644 index 000000000..9576ddfd3 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java @@ -0,0 +1,93 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.entity.PalletEntity; +import com.baiyue.warehouse.mapper.PalletMapper; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.util.Collection; +import java.util.List; + +/** 托盘持久化仓储,所有业务查询均显式包含租户和仓库边界。 */ +@Repository +public class PalletRepository { + private final PalletMapper mapper; + + public PalletRepository(PalletMapper mapper) { + this.mapper = mapper; + } + + public PageResult page(long tenantId, PalletRequests.PageQuery query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(PalletEntity::getTenantId, tenantId) + .eq(query.warehouseId() != null, PalletEntity::getWarehouseId, query.warehouseId()) + .like(StringUtils.hasText(query.palletCode()), PalletEntity::getPalletCode, query.palletCode()) + .eq(StringUtils.hasText(query.palletType()), PalletEntity::getPalletType, query.palletType()) + .orderByAsc(PalletEntity::getWarehouseId) + .orderByAsc(PalletEntity::getPalletCode) + .orderByAsc(PalletEntity::getId); + return PageResult.from(mapper.selectPage(new Page<>(query.page(), query.size()), wrapper)); + } + + public PalletEntity findOwned(long tenantId, long warehouseId, long id) { + return mapper.selectOne(new LambdaQueryWrapper() + .eq(PalletEntity::getTenantId, tenantId) + .eq(PalletEntity::getWarehouseId, warehouseId) + .eq(PalletEntity::getId, id)); + } + + public boolean existsCode(long tenantId, long warehouseId, String code, Long excludedId) { + return mapper.selectCount(new LambdaQueryWrapper() + .eq(PalletEntity::getTenantId, tenantId) + .eq(PalletEntity::getWarehouseId, warehouseId) + .eq(PalletEntity::getPalletCode, code) + .ne(excludedId != null, PalletEntity::getId, excludedId)) > 0; + } + + public List findAvailable(long tenantId, long warehouseId, String palletType, + java.math.BigDecimal capacityCbm, int limit) { + return mapper.selectList(new LambdaQueryWrapper() + .eq(PalletEntity::getTenantId, tenantId) + .eq(PalletEntity::getWarehouseId, warehouseId) + .eq(PalletEntity::getStatus, "AVAILABLE") + .eq(StringUtils.hasText(palletType), PalletEntity::getPalletType, palletType) + .eq(capacityCbm != null, PalletEntity::getCapacityCbm, capacityCbm) + .orderByAsc(PalletEntity::getPalletCode) + .last("LIMIT " + limit)); + } + + public void insert(PalletEntity entity) { + mapper.insert(entity); + } + + public int save(PalletEntity entity) { + return mapper.updateById(entity); + } + + public int logicalDelete(long tenantId, long warehouseId, long id, int version) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(PalletEntity::getTenantId, tenantId) + .eq(PalletEntity::getWarehouseId, warehouseId) + .eq(PalletEntity::getId, id) + .eq(PalletEntity::getVersion, version) + .in(PalletEntity::getStatus, List.of("AVAILABLE", "DISABLED")) + .set(PalletEntity::getDeleted, true) + .set(PalletEntity::getVersion, version + 1)); + } + + public int transitionStatus(long tenantId, long warehouseId, long id, + Collection expectedStatuses, String targetStatus) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(PalletEntity::getTenantId, tenantId) + .eq(PalletEntity::getWarehouseId, warehouseId) + .eq(PalletEntity::getId, id) + .in(PalletEntity::getStatus, expectedStatuses) + .set(PalletEntity::getStatus, targetStatus) + .setSql("version = version + 1")); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationApplicationService.java new file mode 100644 index 000000000..e4ff3b52a --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationApplicationService.java @@ -0,0 +1,206 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.LocationDtos.Detail; +import com.baiyue.warehouse.dto.LocationDtos.PageQuery; +import com.baiyue.warehouse.dto.LocationDtos.SaveRequest; +import com.baiyue.warehouse.entity.LocationEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.repository.LocationRepository; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** 库位基础资料应用服务,负责 CRUD、租户仓库隔离和启停/删除约束。 */ +@Service +public class LocationApplicationService { + private final LocationRepository repository; + + public LocationApplicationService(LocationRepository repository) { + this.repository = repository; + } + + public PageResult page(long tenantId, PageQuery query) { + requireWarehouse(tenantId, query.warehouseId(), false); + return repository.page(tenantId, query); + } + + public Detail detail(long tenantId, long warehouseId, long id) { + LocationEntity location = owned(tenantId, warehouseId, id); + WarehouseEntity warehouse = requireWarehouse(tenantId, warehouseId, false); + location.setWarehouseCode(warehouse.getWarehouseCode()); + location.setWarehouseName(warehouse.getWarehouseName()); + return new Detail(location, warehouse.getWarehouseCode(), warehouse.getWarehouseName()); + } + + @Transactional + @BusinessAudit(event = "LOCATION_CREATE", resourceType = "LOCATION", + resourceId = "#request.locationCode", description = "新增库位", captureBefore = false) + public Long create(long tenantId, SaveRequest request) { + requireUserManagedCode(request.locationCode()); + requireWarehouse(tenantId, request.warehouseId(), "AVAILABLE".equals(request.status())); + ensureCodeUnique(tenantId, request.warehouseId(), request.locationCode(), null); + LocationEntity entity = toEntity(tenantId, request); + try { + repository.insert(entity); + } catch (DataIntegrityViolationException exception) { + throw new BizException(ResultCode.CONFLICT, "库位编码在当前企业仓库内已存在"); + } + return entity.getId(); + } + + @Transactional + @BusinessAudit(event = "LOCATION_UPDATE", resourceType = "LOCATION", + resourceId = "#id", description = "编辑库位") + public void update(long tenantId, long warehouseId, long id, SaveRequest request) { + if (request.version() == null) { + throw new BizException(ResultCode.BAD_REQUEST, "编辑库位必须携带 version"); + } + LocationEntity old = owned(tenantId, warehouseId, id); + requireUserManaged(old); + requireUserManagedCode(request.locationCode()); + if (!warehouseIdEquals(warehouseId, request.warehouseId())) { + throw new BizException(ResultCode.FORBIDDEN, "禁止通过编辑跨仓库移动库位"); + } + if (!old.getStatus().equals(request.status())) { + throw new BizException(ResultCode.FORBIDDEN, "库位状态只能通过启停接口变更"); + } + if ("RESERVED".equals(old.getStatus()) || "OCCUPIED".equals(old.getStatus()) + || repository.hasActiveResource(tenantId, id)) { + throw new BizException(ResultCode.CONFLICT, "预占或占用中的库位不能编辑"); + } + requireWarehouse(tenantId, warehouseId, "AVAILABLE".equals(old.getStatus())); + ensureCodeUnique(tenantId, warehouseId, request.locationCode(), id); + LocationEntity entity = toEntity(tenantId, request); + entity.setId(id); + entity.setVersion(request.version()); + try { + if (repository.save(entity) != 1) { + throw optimisticConflict(); + } + } catch (DataIntegrityViolationException exception) { + throw new BizException(ResultCode.CONFLICT, "库位编码在当前企业仓库内已存在"); + } + } + + @Transactional + @BusinessAudit(event = "LOCATION_STATUS_CHANGE", resourceType = "LOCATION", + resourceId = "#id", description = "启用或停用库位") + public void changeStatus(long tenantId, long warehouseId, long id, String status, int version, long operatorId) { + LocationEntity location = owned(tenantId, warehouseId, id); + requireUserManaged(location); + if (location.getStatus().equals(status)) { + if (!Integer.valueOf(version).equals(location.getVersion())) { + throw optimisticConflict(); + } + return; + } + if ("AVAILABLE".equals(status)) { + requireWarehouse(tenantId, warehouseId, true); + if (!"DISABLED".equals(location.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "只有已停用库位可以重新启用"); + } + } else if ("DISABLED".equals(status)) { + if (!"AVAILABLE".equals(location.getStatus()) || repository.hasActiveResource(tenantId, id)) { + throw new BizException(ResultCode.CONFLICT, "预占或占用中的库位不能停用"); + } + } else { + throw new BizException(ResultCode.BAD_REQUEST, "无效库位状态"); + } + if (repository.changeStatus(tenantId, warehouseId, id, location.getStatus(), status, + version, operatorId) != 1) { + throw optimisticConflict(); + } + } + + @Transactional + @BusinessAudit(event = "LOCATION_DELETE", resourceType = "LOCATION", + resourceId = "#id", description = "删除库位") + public void delete(long tenantId, long warehouseId, long id, int version, long operatorId) { + LocationEntity location = owned(tenantId, warehouseId, id); + requireUserManaged(location); + if (!"DISABLED".equals(location.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "库位必须先停用再删除"); + } + if (repository.hasActiveResource(tenantId, id)) { + throw new BizException(ResultCode.CONFLICT, "存在活动预占或占用的库位不能删除"); + } + if (repository.deleteDisabled(tenantId, warehouseId, id, version, operatorId) != 1) { + throw optimisticConflict(); + } + } + + public List findAvailable(long tenantId, long warehouseId, int limit) { + if (limit < 1 || limit > 200) { + throw new BizException(ResultCode.BAD_REQUEST, "可用库位查询数量必须在 1 到 200 之间"); + } + requireWarehouse(tenantId, warehouseId, true); + return repository.findAvailable(tenantId, warehouseId, limit); + } + + private WarehouseEntity requireWarehouse(long tenantId, long warehouseId, boolean active) { + WarehouseEntity warehouse = repository.findWarehouse(tenantId, warehouseId); + if (warehouse == null) { + throw new BizException(ResultCode.FORBIDDEN, "仓库不属于当前企业租户或不在数据范围内"); + } + if (active && !"ACTIVE".equals(warehouse.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "仓库已停用"); + } + return warehouse; + } + + private LocationEntity owned(long tenantId, long warehouseId, long id) { + LocationEntity entity = repository.findOwned(tenantId, warehouseId, id); + if (entity == null) { + throw new BizException(ResultCode.NOT_FOUND, "库位不存在或超出当前企业仓库数据范围"); + } + return entity; + } + + private void ensureCodeUnique(long tenantId, long warehouseId, String code, Long excludedId) { + if (repository.existsCode(tenantId, warehouseId, code, excludedId)) { + throw new BizException(ResultCode.CONFLICT, "库位编码在当前企业仓库内已存在"); + } + } + + private LocationEntity toEntity(long tenantId, SaveRequest request) { + LocationEntity entity = new LocationEntity(); + entity.setTenantId(tenantId); + entity.setWarehouseId(request.warehouseId()); + entity.setZoneCode(request.zoneCode()); + entity.setZoneName(request.zoneName()); + entity.setLocationCode(request.locationCode()); + entity.setLocationName(request.locationName()); + entity.setLocationType(request.locationType()); + entity.setMaxWeightKg(request.maxWeightKg()); + entity.setSharedOverflow(false); + entity.setStatus(request.status()); + entity.setRemark(request.remark()); + return entity; + } + + private static boolean warehouseIdEquals(long expected, Long actual) { + return actual != null && expected == actual; + } + + private static void requireUserManaged(LocationEntity location) { + if (Boolean.TRUE.equals(location.getSharedOverflow())) { + throw new BizException(ResultCode.CONFLICT, "系统共享溢出位 A0 不能编辑、停用或删除"); + } + } + + private static void requireUserManagedCode(String locationCode) { + if (locationCode != null && "A0".equalsIgnoreCase(locationCode.trim())) { + throw new BizException(ResultCode.CONFLICT, "A0 为系统保留的共享溢出位编码"); + } + } + + private static BizException optimisticConflict() { + return new BizException(ResultCode.CONFLICT, "库位已被其他操作修改,请刷新后重试"); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java new file mode 100644 index 000000000..b1176b999 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java @@ -0,0 +1,339 @@ +package com.baiyue.warehouse.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.LocationDtos.ActiveResource; +import com.baiyue.warehouse.dto.LocationDtos.ConfirmCommand; +import com.baiyue.warehouse.dto.LocationDtos.OccupancyResult; +import com.baiyue.warehouse.dto.LocationDtos.OccupancyTarget; +import com.baiyue.warehouse.dto.LocationDtos.ReleaseCommand; +import com.baiyue.warehouse.dto.LocationDtos.ReservationResult; +import com.baiyue.warehouse.dto.LocationDtos.ReservationTarget; +import com.baiyue.warehouse.dto.LocationDtos.ReserveCommand; +import com.baiyue.warehouse.entity.LocationEntity; +import com.baiyue.warehouse.entity.LocationOccupancyEntity; +import com.baiyue.warehouse.entity.LocationReservationEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.repository.LocationRepository; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 任务7可直接复用的库位资源服务。 + * 所有批量状态变更均在单事务内完成;普通库位不足时共享 A0,并发抢占或唯一键冲突会整批回滚。 + */ +@Service +@Validated +public class LocationResourceService { + private final LocationRepository repository; + + public LocationResourceService(LocationRepository repository) { + this.repository = repository; + } + + public List findAvailable(@Min(1) long tenantId, @Min(1) long warehouseId, + @Min(1) @Max(200) int limit) { + requireLimit(limit); + requireActiveWarehouse(tenantId, warehouseId); + return repository.findAvailable(tenantId, warehouseId, limit); + } + + @Transactional + public List reserveAvailable(@Valid ReserveCommand command) { + int quantity = command.targets().size(); + requireLimit(quantity); + requireActiveWarehouse(command.tenantId(), command.warehouseId()); + if (!command.expiresAt().isAfter(LocalDateTime.now())) { + throw new BizException(ResultCode.BAD_REQUEST, "库位预占过期时间必须晚于当前时间"); + } + validateUniqueTargets(command.targets()); + + Map existing = new LinkedHashMap<>(); + repository.findReservationsByKeys(command.tenantId(), command.sourceService(), + command.targets().stream().map(ReservationTarget::idempotencyKey).toList()) + .forEach(item -> existing.put(item.getIdempotencyKey(), item)); + if (!existing.isEmpty()) { + if (existing.size() != quantity) { + throw new BizException(ResultCode.CONFLICT, "同批库位预占幂等键只完成了一部分,请核对请求"); + } + return command.targets().stream() + .map(target -> existingReservation(command, target, existing.get(target.idempotencyKey()))) + .toList(); + } + + List selected = repository.lockAvailable(command.tenantId(), command.warehouseId(), quantity); + LocationEntity overflow = selected.size() < quantity + ? repository.lockOrCreateSharedOverflow(command.tenantId(), command.warehouseId(), command.operatorId()) + : null; + if (selected.size() < quantity && overflow == null) { + throw new BizException(ResultCode.CONFLICT, "系统共享溢出位 A0 创建或读取失败"); + } + + List result = new ArrayList<>(quantity); + try { + for (int index = 0; index < quantity; index++) { + LocationEntity location = index < selected.size() ? selected.get(index) : overflow; + ReservationTarget target = command.targets().get(index); + if (!Boolean.TRUE.equals(location.getSharedOverflow()) + && repository.changeStatus(command.tenantId(), command.warehouseId(), location.getId(), + "AVAILABLE", "RESERVED", location.getVersion(), command.operatorId()) != 1) { + throw new BizException(ResultCode.CONFLICT, "库位已被并发抢占"); + } + LocationReservationEntity reservation = reservation(command, target, location); + repository.insertReservation(reservation); + result.add(toReservationResult(reservation)); + } + } catch (DataIntegrityViolationException exception) { + throw new BizException(ResultCode.CONFLICT, "库位预占幂等键、托盘或库位已被占用"); + } + return List.copyOf(result); + } + + @Transactional + public List confirmOccupied(@Valid ConfirmCommand command) { + requireLimit(command.targets().size()); + validateUniqueReservationIds(command.targets()); + requireActiveWarehouse(command.tenantId(), command.warehouseId()); + List result = new ArrayList<>(command.targets().size()); + try { + for (OccupancyTarget target : command.targets()) { + LocationReservationEntity reservation = repository.lockReservation(command.tenantId(), target.reservationId()); + requireReservationOwnership(command, reservation); + LocationOccupancyEntity existing = repository.findOccupancyByReservation(command.tenantId(), target.reservationId()); + if ("CONFIRMED".equals(reservation.getReservationStatus())) { + if (existing == null) { + throw new BizException(ResultCode.CONFLICT, "预占已确认但占用事实缺失"); + } + result.add(toOccupancyResult(existing)); + continue; + } + if (!"RESERVED".equals(reservation.getReservationStatus())) { + throw new BizException(ResultCode.CONFLICT, "库位预占已释放或已过期"); + } + if (!reservation.getExpiresAt().isAfter(LocalDateTime.now())) { + throw new BizException(ResultCode.CONFLICT, "库位预占已过期"); + } + LocationEntity location = ownedLocation(reservation); + if (!Boolean.TRUE.equals(reservation.getSharedOverflow()) + && repository.changeStatus(command.tenantId(), command.warehouseId(), location.getId(), + "RESERVED", "OCCUPIED", location.getVersion(), command.operatorId()) != 1) { + throw new BizException(ResultCode.CONFLICT, "库位状态已被并发修改"); + } + LocationOccupancyEntity occupancy = occupancy(command, target, reservation); + repository.insertOccupancy(occupancy); + if (repository.confirmReservation(reservation) != 1) { + throw new BizException(ResultCode.CONFLICT, "库位预占已被并发修改"); + } + result.add(toOccupancyResult(occupancy)); + } + } catch (DataIntegrityViolationException exception) { + throw new BizException(ResultCode.CONFLICT, "库位或托盘已存在活动占用"); + } + return List.copyOf(result); + } + + @Transactional + public List release(@Valid ReleaseCommand command) { + List released = new ArrayList<>(); + for (LocationOccupancyEntity occupancy : repository.findOccupanciesByBusiness( + command.tenantId(), command.sourceService(), command.businessId())) { + if (!"OCCUPIED".equals(occupancy.getOccupancyStatus())) { + continue; + } + LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId()); + if (location == null || repository.releaseOccupancy(occupancy, command.reason()) != 1 + || (!Boolean.TRUE.equals(occupancy.getSharedOverflow()) + && repository.changeStatus(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId(), + "OCCUPIED", "AVAILABLE", location.getVersion(), command.operatorId()) != 1)) { + throw new BizException(ResultCode.CONFLICT, "释放占用时库位状态已被并发修改"); + } + released.add(active(occupancy, "RELEASED")); + } + for (LocationReservationEntity reservation : repository.findReservationsByBusiness( + command.tenantId(), command.sourceService(), command.businessId())) { + if (!"RESERVED".equals(reservation.getReservationStatus())) { + continue; + } + LocationEntity location = ownedLocation(reservation); + if (repository.releaseReservation(reservation, command.reason()) != 1 + || (!Boolean.TRUE.equals(reservation.getSharedOverflow()) + && repository.changeStatus(command.tenantId(), reservation.getWarehouseId(), reservation.getLocationId(), + "RESERVED", "AVAILABLE", location.getVersion(), command.operatorId()) != 1)) { + throw new BizException(ResultCode.CONFLICT, "释放预占时库位状态已被并发修改"); + } + released.add(active(reservation, "RELEASED")); + } + return List.copyOf(released); + } + + @Transactional(readOnly = true) + public List findActiveResources(@Min(1) long tenantId, + @NotBlank String sourceService, + @Min(1) long businessId) { + List result = new ArrayList<>(); + repository.findReservationsByBusiness(tenantId, sourceService, businessId).stream() + .filter(item -> "RESERVED".equals(item.getReservationStatus())) + .map(item -> active(item, "RESERVED")) + .forEach(result::add); + repository.findOccupanciesByBusiness(tenantId, sourceService, businessId).stream() + .filter(item -> "OCCUPIED".equals(item.getOccupancyStatus())) + .map(item -> active(item, "OCCUPIED")) + .forEach(result::add); + return List.copyOf(result); + } + + private LocationReservationEntity reservation(ReserveCommand command, ReservationTarget target, + LocationEntity location) { + long id = IdWorker.getId(); + LocationReservationEntity entity = new LocationReservationEntity(); + entity.setId(id); + entity.setTenantId(command.tenantId()); + entity.setReservationNo("LR" + id); + entity.setIdempotencyKey(target.idempotencyKey()); + entity.setSourceService(command.sourceService()); + entity.setBusinessId(command.businessId()); + entity.setInboundCode(command.inboundCode()); + entity.setWarehouseId(command.warehouseId()); + entity.setPalletId(target.palletId()); + entity.setPalletCode(target.palletCode()); + entity.setLocationId(location.getId()); + entity.setLocationCode(location.getLocationCode()); + entity.setSharedOverflow(Boolean.TRUE.equals(location.getSharedOverflow())); + entity.setReservationStatus("RESERVED"); + entity.setExpiresAt(command.expiresAt()); + entity.setCreatedBy(command.operatorId()); + entity.setVersion(0); + return entity; + } + + private LocationOccupancyEntity occupancy(ConfirmCommand command, OccupancyTarget target, + LocationReservationEntity reservation) { + long id = IdWorker.getId(); + LocationOccupancyEntity entity = new LocationOccupancyEntity(); + entity.setId(id); + entity.setTenantId(command.tenantId()); + entity.setOccupancyNo("LO" + id); + entity.setReservationId(reservation.getId()); + entity.setSourceService(reservation.getSourceService()); + entity.setBusinessId(reservation.getBusinessId()); + entity.setInboundCode(reservation.getInboundCode()); + entity.setWarehouseId(reservation.getWarehouseId()); + entity.setPalletId(reservation.getPalletId()); + entity.setPalletCode(reservation.getPalletCode()); + entity.setLocationId(reservation.getLocationId()); + entity.setLocationCode(reservation.getLocationCode()); + entity.setSharedOverflow(Boolean.TRUE.equals(reservation.getSharedOverflow())); + entity.setAllocatedVolumeCbm(target.allocatedVolumeCbm()); + entity.setAllocatedPackageCount(target.allocatedPackageCount()); + entity.setOccupancyStatus("OCCUPIED"); + entity.setCreatedBy(command.operatorId()); + entity.setVersion(0); + return entity; + } + + private ReservationResult existingReservation(ReserveCommand command, ReservationTarget target, + LocationReservationEntity reservation) { + if (reservation == null || reservation.getBusinessId() != command.businessId() + || reservation.getWarehouseId() != command.warehouseId() + || reservation.getPalletId() != target.palletId() + || !reservation.getPalletCode().equals(target.palletCode())) { + throw new BizException(ResultCode.CONFLICT, "库位预占幂等键已用于其他业务或托盘"); + } + if ("RELEASED".equals(reservation.getReservationStatus()) || "EXPIRED".equals(reservation.getReservationStatus())) { + throw new BizException(ResultCode.CONFLICT, "库位预占幂等请求已释放或过期"); + } + return toReservationResult(reservation); + } + + private void requireReservationOwnership(ConfirmCommand command, LocationReservationEntity reservation) { + if (reservation == null || reservation.getWarehouseId() != command.warehouseId() + || reservation.getBusinessId() != command.businessId() + || !reservation.getSourceService().equals(command.sourceService())) { + throw new BizException(ResultCode.FORBIDDEN, "库位预占不属于当前租户、仓库或业务"); + } + } + + private LocationEntity ownedLocation(LocationReservationEntity reservation) { + LocationEntity location = repository.findOwned(reservation.getTenantId(), reservation.getWarehouseId(), + reservation.getLocationId()); + if (location == null) { + throw new BizException(ResultCode.CONFLICT, "预占关联库位不存在"); + } + return location; + } + + private void requireActiveWarehouse(long tenantId, long warehouseId) { + WarehouseEntity warehouse = repository.findWarehouse(tenantId, warehouseId); + if (warehouse == null) { + throw new BizException(ResultCode.FORBIDDEN, "仓库不属于当前企业租户或不在数据范围内"); + } + if (!"ACTIVE".equals(warehouse.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "仓库已停用"); + } + } + + private static void validateUniqueTargets(List targets) { + Set pallets = new HashSet<>(); + Set keys = new HashSet<>(); + for (ReservationTarget target : targets) { + if (!pallets.add(target.palletId()) || !keys.add(target.idempotencyKey())) { + throw new BizException(ResultCode.BAD_REQUEST, "同批预占的托盘和幂等键不得重复"); + } + } + } + + private static void validateUniqueReservationIds(List targets) { + Set ids = new HashSet<>(); + for (OccupancyTarget target : targets) { + if (!ids.add(target.reservationId())) { + throw new BizException(ResultCode.BAD_REQUEST, "同批确认的预占 ID 不得重复"); + } + } + } + + private static void requireLimit(int limit) { + if (limit < 1 || limit > 200) { + throw new BizException(ResultCode.BAD_REQUEST, "库位数量必须在 1 到 200 之间"); + } + } + + private static ReservationResult toReservationResult(LocationReservationEntity entity) { + return new ReservationResult(entity.getId(), entity.getReservationNo(), entity.getPalletId(), + entity.getPalletCode(), entity.getLocationId(), entity.getLocationCode(), + Boolean.TRUE.equals(entity.getSharedOverflow()), entity.getExpiresAt()); + } + + private static OccupancyResult toOccupancyResult(LocationOccupancyEntity entity) { + return new OccupancyResult(entity.getId(), entity.getOccupancyNo(), entity.getReservationId(), + entity.getPalletId(), entity.getLocationId(), entity.getLocationCode(), + Boolean.TRUE.equals(entity.getSharedOverflow())); + } + + private static ActiveResource active(LocationReservationEntity entity, String state) { + return new ActiveResource(entity.getId(), null, entity.getWarehouseId(), entity.getPalletId(), + entity.getPalletCode(), entity.getLocationId(), entity.getLocationCode(), + Boolean.TRUE.equals(entity.getSharedOverflow()), state); + } + + private static ActiveResource active(LocationOccupancyEntity entity, String state) { + return new ActiveResource(entity.getReservationId(), entity.getId(), entity.getWarehouseId(), + entity.getPalletId(), entity.getPalletCode(), entity.getLocationId(), entity.getLocationCode(), + Boolean.TRUE.equals(entity.getSharedOverflow()), state); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java new file mode 100644 index 000000000..1cdf915d0 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java @@ -0,0 +1,113 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.entity.PalletEntity; +import com.baiyue.warehouse.repository.PalletRepository; +import com.baiyue.warehouse.repository.WarehouseRepository; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** 托盘主数据应用服务。 */ +@Service +public class PalletApplicationService { + private final PalletRepository repository; + private final WarehouseRepository warehouseRepository; + + public PalletApplicationService(PalletRepository repository, WarehouseRepository warehouseRepository) { + this.repository = repository; + this.warehouseRepository = warehouseRepository; + } + + public PageResult page(long tenantId, PalletRequests.PageQuery query) { + PageResult source = repository.page(tenantId, query); + return new PageResult<>(source.records().stream().map(PalletApplicationService::view).toList(), + source.page(), source.size(), source.total(), source.pages()); + } + + public PalletRequests.View detail(long tenantId, long warehouseId, long id) { + return view(owned(tenantId, warehouseId, id)); + } + + @Transactional + @BusinessAudit(event = "PALLET_CREATE", resourceType = "PALLET", description = "新增托盘", captureBefore = false) + public Long create(long tenantId, PalletRequests.SaveRequest request) { + requireWarehouse(tenantId, request.warehouseId()); + ensureCodeUnique(tenantId, request.warehouseId(), request.palletCode(), null); + PalletEntity entity = new PalletEntity(); + entity.setTenantId(tenantId); + entity.setWarehouseId(request.warehouseId()); + copy(entity, request); + entity.setStatus("AVAILABLE"); + try { + repository.insert(entity); + } catch (DataIntegrityViolationException exception) { + throw new BizException(ResultCode.CONFLICT, "托盘编码已存在"); + } + return entity.getId(); + } + + @Transactional + @BusinessAudit(event = "PALLET_UPDATE", resourceType = "PALLET", resourceId = "#id", description = "编辑托盘") + public void update(long tenantId, long warehouseId, long id, PalletRequests.SaveRequest request) { + if (warehouseId != request.warehouseId()) { + throw new BizException(ResultCode.FORBIDDEN, "请求仓库与托盘所属仓库不一致"); + } + PalletEntity entity = owned(tenantId, warehouseId, id); + ensureCodeUnique(tenantId, warehouseId, request.palletCode(), id); + copy(entity, request); + try { + if (repository.save(entity) != 1) { + throw new BizException(ResultCode.CONFLICT, "托盘已被并发修改,请刷新后重试"); + } + } catch (DataIntegrityViolationException exception) { + throw new BizException(ResultCode.CONFLICT, "托盘编码已存在"); + } + } + + @Transactional + @BusinessAudit(event = "PALLET_DELETE", resourceType = "PALLET", resourceId = "#id", description = "删除托盘") + public void delete(long tenantId, long warehouseId, long id) { + PalletEntity entity = owned(tenantId, warehouseId, id); + if (repository.logicalDelete(tenantId, warehouseId, id, entity.getVersion()) != 1) { + throw new BizException(ResultCode.CONFLICT, "预占、占用或已被并发修改的托盘不能删除"); + } + } + + private PalletEntity owned(long tenantId, long warehouseId, long id) { + PalletEntity entity = repository.findOwned(tenantId, warehouseId, id); + if (entity == null) { + throw new BizException(ResultCode.NOT_FOUND, "托盘不存在或不属于当前租户和仓库"); + } + return entity; + } + + private void requireWarehouse(long tenantId, long warehouseId) { + if (warehouseRepository.findOwned(tenantId, warehouseId) == null) { + throw new BizException(ResultCode.FORBIDDEN, "仓库不存在或不属于目标租户"); + } + } + + private void ensureCodeUnique(long tenantId, long warehouseId, String code, Long excludedId) { + if (repository.existsCode(tenantId, warehouseId, code, excludedId)) { + throw new BizException(ResultCode.CONFLICT, "托盘编码已存在"); + } + } + + private static void copy(PalletEntity entity, PalletRequests.SaveRequest request) { + entity.setPalletCode(request.palletCode()); + entity.setPalletType(request.palletType()); + entity.setCapacityCbm(request.capacityCbm()); + entity.setRemark(request.remark()); + } + + private static PalletRequests.View view(PalletEntity entity) { + return new PalletRequests.View(entity.getId(), entity.getTenantId(), entity.getWarehouseId(), + entity.getPalletCode(), entity.getPalletType(), entity.getCapacityCbm(), entity.getRemark(), + entity.getCreatedAt(), entity.getUpdatedAt()); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java new file mode 100644 index 000000000..e385d9ae1 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java @@ -0,0 +1,155 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.LocationDtos; +import com.baiyue.warehouse.dto.PalletAllocationDtos; +import com.baiyue.warehouse.entity.PalletEntity; +import com.baiyue.warehouse.repository.PalletRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * 托盘拆分和一托一位编排。库位资源操作全部委托给任务6的 {@link LocationResourceService}。 + */ +@Service +public class PalletLocationAllocationService { + /** 测量完成后默认推荐的普通托盘类型编码。 */ + public static final String DEFAULT_PALLET_TYPE = "STANDARD"; + private final PalletRepository palletRepository; + private final PalletVolumeSplitter splitter; + private final LocationResourceService locationResourceService; + + public PalletLocationAllocationService(PalletRepository palletRepository, + PalletVolumeSplitter splitter, + LocationResourceService locationResourceService) { + this.palletRepository = palletRepository; + this.splitter = splitter; + this.locationResourceService = locationResourceService; + } + + @Transactional + @BusinessAudit(event = "PALLET_LOCATION_RESERVE", resourceType = "INBOUND", resourceId = "#request.businessId", description = "预占托盘和库位", captureBefore = false) + public PalletAllocationDtos.BatchResult reserve(PalletAllocationDtos.ReserveRequest request) { + if (!Boolean.TRUE.equals(request.measurementCompleted())) { + throw new BizException(ResultCode.CONFLICT, "只能使用已完成测量的总体积拆分托盘并占用库位"); + } + var split = splitter.split(request.totalVolumeCbm(), request.palletCapacityCbm()); + List existing = active(request.tenantId(), request.sourceService(), request.businessId()); + if (!existing.isEmpty()) { + return fromActive(existing, split); + } + + String palletType = org.springframework.util.StringUtils.hasText(request.palletType()) + ? request.palletType() : DEFAULT_PALLET_TYPE; + List pallets = palletRepository.findAvailable(request.tenantId(), request.warehouseId(), + palletType, request.palletCapacityCbm(), split.palletCount()); + if (pallets.size() != split.palletCount()) { + throw new BizException(ResultCode.CONFLICT, "可用托盘数量不足"); + } + + List targets = new ArrayList<>(pallets.size()); + for (int index = 0; index < pallets.size(); index++) { + PalletEntity pallet = pallets.get(index); + if (palletRepository.transitionStatus(request.tenantId(), request.warehouseId(), pallet.getId(), + List.of("AVAILABLE"), "RESERVED") != 1) { + throw new BizException(ResultCode.CONFLICT, "托盘被并发预占,请重试"); + } + targets.add(new LocationDtos.ReservationTarget(pallet.getId(), pallet.getPalletCode(), + request.idempotencyKey() + ":" + (index + 1))); + } + + List reservations = locationResourceService.reserveAvailable( + new LocationDtos.ReserveCommand(request.tenantId(), request.warehouseId(), request.sourceService(), + request.businessId(), request.inboundCode(), request.operatorId(), request.expiresAt(), targets)); + if (reservations.size() != split.palletCount()) { + throw new BizException(ResultCode.CONFLICT, "库位预占结果数量不一致"); + } + List results = new ArrayList<>(reservations.size()); + for (int index = 0; index < reservations.size(); index++) { + var item = reservations.get(index); + results.add(new PalletAllocationDtos.AllocationResult(item.reservationId(), item.reservationNo(), index + 1, + item.palletId(), item.palletCode(), item.locationId(), item.locationCode(), + split.allocations().get(index).allocatedVolumeCbm(), "RESERVED")); + } + return new PalletAllocationDtos.BatchResult(split.palletCount(), List.copyOf(results)); + } + + @Transactional + @BusinessAudit(event = "PALLET_LOCATION_CONFIRM", resourceType = "INBOUND", resourceId = "#request.businessId", description = "确认托盘和库位占用") + public PalletAllocationDtos.BatchResult confirm(PalletAllocationDtos.ConfirmRequest request) { + var split = splitter.split(request.totalVolumeCbm(), request.palletCapacityCbm()); + List active = active(request.tenantId(), request.sourceService(), request.businessId()); + if (active.size() != split.palletCount()) { + throw new BizException(ResultCode.CONFLICT, "活动预占数量与托盘拆分数量不一致"); + } + active.sort(Comparator.comparingLong(LocationDtos.ActiveResource::reservationId)); + List targets = new ArrayList<>(active.size()); + for (int index = 0; index < active.size(); index++) { + targets.add(new LocationDtos.OccupancyTarget(active.get(index).reservationId(), + split.allocations().get(index).allocatedVolumeCbm(), null)); + } + List occupied = locationResourceService.confirmOccupied( + new LocationDtos.ConfirmCommand(request.tenantId(), request.warehouseId(), request.sourceService(), + request.businessId(), request.operatorId(), targets)); + for (LocationDtos.ActiveResource resource : active) { + if ("RESERVED".equals(resource.state()) + && palletRepository.transitionStatus(request.tenantId(), request.warehouseId(), resource.palletId(), + List.of("RESERVED"), "OCCUPIED") != 1) { + throw new BizException(ResultCode.CONFLICT, "托盘确认占用发生并发冲突"); + } + } + List results = new ArrayList<>(occupied.size()); + for (int index = 0; index < occupied.size(); index++) { + var item = occupied.get(index); + results.add(new PalletAllocationDtos.AllocationResult(item.reservationId(), null, index + 1, + item.palletId(), active.get(index).palletCode(), item.locationId(), item.locationCode(), + split.allocations().get(index).allocatedVolumeCbm(), "OCCUPIED")); + } + return new PalletAllocationDtos.BatchResult(split.palletCount(), List.copyOf(results)); + } + + @Transactional + @BusinessAudit(event = "PALLET_LOCATION_RELEASE", resourceType = "INBOUND", resourceId = "#request.businessId", description = "补偿释放托盘和库位预占") + public List release(PalletAllocationDtos.ReleaseRequest request) { + List active = active(request.tenantId(), request.sourceService(), request.businessId()); + if (active.stream().anyMatch(item -> "OCCUPIED".equals(item.state()))) { + throw new BizException(ResultCode.CONFLICT, "已确认实物入库的占用不能通过补偿接口释放"); + } + List released = locationResourceService.release( + new LocationDtos.ReleaseCommand(request.tenantId(), request.sourceService(), request.businessId(), + request.operatorId(), request.reason())); + for (LocationDtos.ActiveResource resource : released) { + if (palletRepository.transitionStatus(request.tenantId(), resource.warehouseId(), resource.palletId(), + List.of("RESERVED"), "AVAILABLE") != 1) { + throw new BizException(ResultCode.CONFLICT, "托盘释放发生并发冲突"); + } + } + return released; + } + + private List active(long tenantId, String sourceService, long businessId) { + return new ArrayList<>(locationResourceService.findActiveResources(tenantId, sourceService, businessId)); + } + + private static PalletAllocationDtos.BatchResult fromActive(List active, + PalletVolumeSplitter.SplitResult split) { + if (active.size() != split.palletCount()) { + throw new BizException(ResultCode.CONFLICT, "幂等请求与已有托盘拆分结果不一致"); + } + active.sort(Comparator.comparingLong(LocationDtos.ActiveResource::reservationId)); + List results = new ArrayList<>(active.size()); + for (int index = 0; index < active.size(); index++) { + var item = active.get(index); + results.add(new PalletAllocationDtos.AllocationResult(item.reservationId(), null, index + 1, + item.palletId(), item.palletCode(), item.locationId(), item.locationCode(), + split.allocations().get(index).allocatedVolumeCbm(), item.state())); + } + return new PalletAllocationDtos.BatchResult(split.palletCount(), List.copyOf(results)); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletVolumeSplitter.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletVolumeSplitter.java new file mode 100644 index 000000000..f2bcd730a --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletVolumeSplitter.java @@ -0,0 +1,52 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; + +/** 按托盘容积向上拆分入库总体积,不涉及SKU库存。 */ +@Component +public class PalletVolumeSplitter { + static final int MAX_PALLET_COUNT = 200; + + public SplitResult split(BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm) { + requirePositive(totalVolumeCbm, "总体积必须大于0"); + requirePositive(palletCapacityCbm, "托盘容积必须大于0"); + final int palletCount; + try { + palletCount = totalVolumeCbm.divide(palletCapacityCbm, 0, RoundingMode.CEILING).intValueExact(); + } catch (ArithmeticException exception) { + throw new BizException(ResultCode.VALIDATION_FAILED, "托盘拆分数量超出系统处理范围"); + } + if (palletCount > MAX_PALLET_COUNT) { + throw new BizException(ResultCode.VALIDATION_FAILED, "单次最多拆分200个托盘"); + } + + List allocations = new ArrayList<>(palletCount); + for (int index = 1; index <= palletCount; index++) { + BigDecimal allocated = index == palletCount + ? totalVolumeCbm.subtract(palletCapacityCbm.multiply(BigDecimal.valueOf(palletCount - 1L))) + : palletCapacityCbm; + allocations.add(new Allocation(index, allocated)); + } + return new SplitResult(totalVolumeCbm, palletCapacityCbm, palletCount, List.copyOf(allocations)); + } + + private static void requirePositive(BigDecimal value, String message) { + if (value == null || value.signum() <= 0) { + throw new BizException(ResultCode.VALIDATION_FAILED, message); + } + } + + public record Allocation(int allocationNo, BigDecimal allocatedVolumeCbm) { + } + + public record SplitResult(BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm, + int palletCount, List allocations) { + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java index dfa7ada51..3f41617ab 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java @@ -18,7 +18,10 @@ public class WarehouseSchemaReadinessService { "SELECT id, tenant_id, warehouse_id, capability_code, capability_name, enabled, deleted FROM wh_service_capability LIMIT 0", "SELECT id, tenant_id, data_scope_id, partner_code, partner_name, partner_type, status, remark, deleted FROM wh_partner LIMIT 0", "SELECT partner_id, tenant_id, data_scope_id, partner_type FROM wh_partner_type LIMIT 0", - "SELECT id, tenant_id, company_code, company_name, status, deleted FROM wh_express_company LIMIT 0" + "SELECT id, tenant_id, company_code, company_name, status, deleted FROM wh_express_company LIMIT 0", + "SELECT id, tenant_id, warehouse_id, zone_code, zone_name, location_code, location_type, max_weight_kg, shared_overflow, status, deleted, version FROM wh_location LIMIT 0", + "SELECT id, tenant_id, warehouse_id, pallet_id, location_id, shared_overflow, reservation_status, expires_at, version FROM wh_location_reservation LIMIT 0", + "SELECT id, tenant_id, warehouse_id, pallet_id, location_id, shared_overflow, occupancy_status, active_location_occupancy, active_pallet_occupancy, version FROM wh_location_occupancy LIMIT 0" ); private final JdbcTemplate jdbcTemplate; @@ -32,7 +35,7 @@ public class WarehouseSchemaReadinessService { PROBES.forEach(jdbcTemplate::execute); return new Readiness(true, "UP", "仓储数据库表结构已就绪"); } catch (DataAccessException exception) { - return new Readiness(false, "DEGRADED", "仓储数据库表结构未就绪,请执行一期迁移脚本"); + return new Readiness(false, "DEGRADED", "仓储数据库表结构未就绪,请执行一期及二期库位迁移脚本"); } } diff --git a/warehouse/src/main/resources/db/phase2-location-overflow-migration.sql b/warehouse/src/main/resources/db/phase2-location-overflow-migration.sql new file mode 100644 index 000000000..8daa1ced7 --- /dev/null +++ b/warehouse/src/main/resources/db/phase2-location-overflow-migration.sql @@ -0,0 +1,137 @@ +-- 二期库位共享溢出位 A0 增量迁移。 +-- 执行顺序:phase2-pallet-location-schema.sql、本文件。 +-- 普通库位仍保持一托一位;同一租户仓库只有一个 A0,且 A0 可承载多个活动资源。 +SET NAMES utf8mb4; +USE baiyue_warehouse; + +DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; +DELIMITER $$ +CREATE PROCEDURE add_location_overflow_column_if_missing( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_column_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND column_name = p_column_name + ) THEN + SET @column_sql = CONCAT( + 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), + '` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), '` ', p_column_definition + ); + PREPARE column_statement FROM @column_sql; + EXECUTE column_statement; + DEALLOCATE PREPARE column_statement; + END IF; +END$$ +DELIMITER ; + +CALL add_location_overflow_column_if_missing( + 'wh_location', 'shared_overflow', + 'TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否为系统共享溢出位 A0'' AFTER max_weight_kg' +); +CALL add_location_overflow_column_if_missing( + 'wh_location_reservation', 'shared_overflow', + 'TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否使用共享溢出位 A0'' AFTER location_code' +); +CALL add_location_overflow_column_if_missing( + 'wh_location_occupancy', 'shared_overflow', + 'TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否使用共享溢出位 A0'' AFTER location_code' +); + +-- 兼容迁移前人工或旧流程已创建的 A0,并将资源快照回填一致。 +UPDATE wh_location + SET shared_overflow = 1, + status = 'AVAILABLE' + WHERE location_code = 'A0' AND deleted = 0; + +UPDATE wh_location_reservation r +JOIN wh_location l + ON l.tenant_id = r.tenant_id AND l.id = r.location_id + SET r.shared_overflow = l.shared_overflow; + +UPDATE wh_location_occupancy o +JOIN wh_location l + ON l.tenant_id = o.tenant_id AND l.id = o.location_id + SET o.shared_overflow = l.shared_overflow; + +DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; + +-- 先移除依赖旧生成列的唯一索引,再按普通库位/A0 的不同语义重建。 +SET @schema_name = DATABASE(); +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_location_active_reservation') > 0, + 'ALTER TABLE wh_location_reservation DROP INDEX uk_location_active_reservation', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_pallet_active_reservation') > 0, + 'ALTER TABLE wh_location_reservation DROP INDEX uk_pallet_active_reservation', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + +ALTER TABLE wh_location_reservation + MODIFY COLUMN active_reservation TINYINT + GENERATED ALWAYS AS ( + CASE WHEN reservation_status = 'RESERVED' AND shared_overflow = 0 THEN 1 ELSE NULL END + ) STORED COMMENT '普通库位活动预占唯一标记,A0 返回 NULL'; + +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_location_active_reservation') = 0, + 'ALTER TABLE wh_location_reservation ADD UNIQUE KEY uk_location_active_reservation (tenant_id, location_id, active_reservation)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_location_active_occupancy') > 0, + 'ALTER TABLE wh_location_occupancy DROP INDEX uk_location_active_occupancy', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_pallet_active_occupancy') > 0, + 'ALTER TABLE wh_location_occupancy DROP INDEX uk_pallet_active_occupancy', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + +DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; +DELIMITER $$ +CREATE PROCEDURE add_location_overflow_column_if_missing( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_column_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND column_name = p_column_name + ) THEN + SET @column_sql = CONCAT( + 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), + '` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), '` ', p_column_definition + ); + PREPARE column_statement FROM @column_sql; + EXECUTE column_statement; + DEALLOCATE PREPARE column_statement; + END IF; +END$$ +DELIMITER ; + +CALL add_location_overflow_column_if_missing( + 'wh_location_reservation', 'active_pallet_reservation', + 'TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = ''RESERVED'' THEN 1 ELSE NULL END) STORED COMMENT ''托盘活动预占唯一标记(包含 A0)''' +); +CALL add_location_overflow_column_if_missing( + 'wh_location_occupancy', 'active_location_occupancy', + 'TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = ''OCCUPIED'' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT ''普通库位活动占用唯一标记''' +); +CALL add_location_overflow_column_if_missing( + 'wh_location_occupancy', 'active_pallet_occupancy', + 'TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = ''OCCUPIED'' THEN 1 ELSE NULL END) STORED COMMENT ''托盘活动占用唯一标记(包含 A0)''' +); +DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; + +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_pallet_active_reservation') = 0, + 'ALTER TABLE wh_location_reservation ADD UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_location_active_occupancy') = 0, + 'ALTER TABLE wh_location_occupancy ADD UNIQUE KEY uk_location_active_occupancy (tenant_id, location_id, active_location_occupancy)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_pallet_active_occupancy') = 0, + 'ALTER TABLE wh_location_occupancy ADD UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_pallet_occupancy)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; diff --git a/warehouse/src/main/resources/db/phase2-pallet-location-schema-migration.sql b/warehouse/src/main/resources/db/phase2-pallet-location-schema-migration.sql new file mode 100644 index 000000000..4ee1e3b18 --- /dev/null +++ b/warehouse/src/main/resources/db/phase2-pallet-location-schema-migration.sql @@ -0,0 +1,46 @@ +-- 二期托盘、库位表的可重复执行增量迁移。 +-- 执行顺序:phase2-pallet-location-schema.sql 之后。 +SET NAMES utf8mb4; +USE baiyue_warehouse; + +-- 活动预占必须同时保证“一托一位”;A0 共享只放宽库位,不放宽托盘唯一性。 +SET @schema_name = DATABASE(); +DROP PROCEDURE IF EXISTS add_pallet_reservation_column_if_missing; +DELIMITER $$ +CREATE PROCEDURE add_pallet_reservation_column_if_missing() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'wh_location_reservation' + AND column_name = 'active_pallet_reservation' + ) THEN + ALTER TABLE wh_location_reservation + ADD COLUMN active_pallet_reservation TINYINT + GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' THEN 1 ELSE NULL END) + STORED COMMENT '托盘活动预占唯一标记(包含A0)'; + END IF; +END$$ +DELIMITER ; +CALL add_pallet_reservation_column_if_missing(); +DROP PROCEDURE IF EXISTS add_pallet_reservation_column_if_missing; + +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = @schema_name + AND table_name = 'wh_location_reservation' + AND index_name = 'uk_pallet_active_reservation') > 0, + 'ALTER TABLE wh_location_reservation DROP INDEX uk_pallet_active_reservation', + 'SELECT 1'); +PREPARE statement FROM @sql; +EXECUTE statement; +DEALLOCATE PREPARE statement; + +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = @schema_name + AND table_name = 'wh_location_reservation' + AND index_name = 'uk_pallet_active_reservation') = 0, + 'ALTER TABLE wh_location_reservation ADD UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation)', + 'SELECT 1'); +PREPARE statement FROM @sql; +EXECUTE statement; +DEALLOCATE PREPARE statement; diff --git a/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql b/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql new file mode 100644 index 000000000..ce13e1bf4 --- /dev/null +++ b/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql @@ -0,0 +1,139 @@ +-- 二期托盘、库位、预占与占用表结构。 +-- 本脚本只新增表、字段、索引和约束,不包含业务数据及流程实现。 +-- 可在 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', + 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 '最大承重展示值,二期不作容量拦截', + shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为系统共享溢出位A0', + 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 '库位编码快照', + shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否使用共享溢出位A0', + 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' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动预占唯一标记', + active_pallet_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' THEN 1 ELSE NULL END) STORED COMMENT '托盘活动预占唯一标记(包含A0)', + 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), + UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_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 '库位编码快照', + shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否使用共享溢出位A0', + 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_location_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动占用唯一标记', + active_pallet_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' THEN 1 ELSE NULL END) STORED COMMENT '托盘活动占用唯一标记(包含A0)', + 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_location_occupancy), + UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_pallet_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='已确认的一托一库位占用事实记录'; + +-- 头程入库单属于其他数据库,不建立跨库外键。 diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformLocationControllerContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformLocationControllerContractTest.java new file mode 100644 index 000000000..2f540deb5 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformLocationControllerContractTest.java @@ -0,0 +1,41 @@ +package com.baiyue.warehouse.controller; + +import io.swagger.v3.oas.annotations.Operation; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformLocationControllerContractTest { + @Test + void 库位接口路径权限和Swagger说明完整() { + assertThat(PlatformLocationController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/api/platform/v1/locations"); + assertMethod("page", GetMapping.class, "platform:location:list"); + assertMethod("detail", GetMapping.class, "platform:location:list"); + assertMethod("available", GetMapping.class, "platform:location:list"); + assertMethod("create", PostMapping.class, "platform:location:create"); + assertMethod("update", PutMapping.class, "platform:location:edit"); + assertMethod("changeStatus", PatchMapping.class, "platform:location:status"); + assertMethod("delete", DeleteMapping.class, "platform:location:delete"); + } + + private static void assertMethod(String name, Class mapping, String permission) { + Method method = java.util.Arrays.stream(PlatformLocationController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(name)) + .findFirst().orElseThrow(); + assertThat(method.getAnnotation(mapping)).isNotNull(); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + permission + "')"); + assertThat(method.getAnnotation(Operation.class).summary()).isNotBlank(); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformPalletControllerContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformPalletControllerContractTest.java new file mode 100644 index 000000000..b1d0a3183 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformPalletControllerContractTest.java @@ -0,0 +1,38 @@ +package com.baiyue.warehouse.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformPalletControllerContractTest { + @Test + void 托盘接口路径和按钮权限完整() { + assertThat(PlatformPalletController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/api/platform/v1/pallets"); + assertMethod("page", GetMapping.class, "platform:pallet:list"); + assertMethod("detail", GetMapping.class, "platform:pallet:list"); + assertMethod("create", PostMapping.class, "platform:pallet:create"); + assertMethod("update", PutMapping.class, "platform:pallet:edit"); + assertMethod("delete", DeleteMapping.class, "platform:pallet:delete"); + assertThat(Arrays.stream(PlatformPalletController.class.getDeclaredMethods()) + .noneMatch(method -> method.getName().equals("status"))).isTrue(); + } + + private static void assertMethod(String name, Class mapping, String authority) { + Method method = Arrays.stream(PlatformPalletController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow(); + assertThat(method.getAnnotation(mapping)).isNotNull(); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + authority + "')"); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/mapper/LocationMapperContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/mapper/LocationMapperContractTest.java new file mode 100644 index 000000000..2c5fdddb6 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/mapper/LocationMapperContractTest.java @@ -0,0 +1,53 @@ +package com.baiyue.warehouse.mapper; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class LocationMapperContractTest { + @Test + void 可用库位按库区和库位数字自然顺序临近分配() throws NoSuchMethodException { + String selectSql = sql(LocationMapper.class.getMethod( + "selectAvailable", long.class, long.class, int.class).getAnnotation(Select.class).value()); + String lockSql = sql(LocationMapper.class.getMethod( + "lockAvailable", long.class, long.class, int.class).getAnnotation(Select.class).value()); + + assertNearbyOrder(selectSql); + assertNearbyOrder(lockSql); + assertThat(lockSql).contains("FOR UPDATE SKIP LOCKED"); + } + + @Test + void A0依靠唯一编码并发创建后加锁复用() throws NoSuchMethodException { + Method insert = LocationMapper.class.getMethod( + "insertSharedOverflowIfAbsent", long.class, long.class, long.class, long.class); + Method lock = LocationMapper.class.getMethod("lockSharedOverflow", long.class, long.class); + + assertThat(sql(insert.getAnnotation(Insert.class).value())) + .contains("location_code, location_name") + .contains("'A0'") + .contains("shared_overflow") + .contains("ON DUPLICATE KEY UPDATE id = id"); + assertThat(sql(lock.getAnnotation(Select.class).value())) + .contains("tenant_id = #{tenantId}") + .contains("warehouse_id = #{warehouseId}") + .contains("location_code = 'A0'") + .contains("FOR UPDATE"); + } + + private static void assertNearbyOrder(String sql) { + assertThat(sql).contains("shared_overflow = 0") + .contains("ORDER BY l.zone_code") + .contains("REGEXP_REPLACE(l.location_code, '[0-9]+$', '')") + .contains("REGEXP_SUBSTR(l.location_code, '[0-9]+$')") + .contains("AS UNSIGNED"); + } + + private static String sql(String[] fragments) { + return String.join(" ", fragments); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java index 1f8fabfba..303e86d84 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java @@ -40,6 +40,43 @@ class WarehouseSchemaContractTest { assertBaseColumns(sql); } + @Test + void 二期库位共用表覆盖基础资料预占占用和并发约束() throws IOException { + String sql = read("phase2-pallet-location-schema.sql"); + assertColumns(sql, "wh_location", "tenant_id", "warehouse_id", "zone_code", "zone_name", + "location_code", "location_type", "max_weight_kg", "shared_overflow", "status", "remark", "deleted", "version"); + assertColumns(sql, "wh_location_reservation", "idempotency_key", "business_id", "pallet_id", + "location_id", "shared_overflow", "reservation_status", "expires_at", + "active_reservation", "active_pallet_reservation", "version"); + assertColumns(sql, "wh_location_occupancy", "reservation_id", "business_id", "pallet_id", + "location_id", "shared_overflow", "occupancy_status", + "active_location_occupancy", "active_pallet_occupancy", "version"); + assertThat(sql).contains("uk_location_tenant_warehouse_code") + .contains("uk_location_active_reservation") + .contains("uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation)") + .contains("uk_location_active_occupancy") + .contains("uk_pallet_active_occupancy"); + } + + @Test + void A0迁移允许共享库位多占用但仍限制托盘唯一活动占用() throws IOException { + String sql = read("phase2-location-overflow-migration.sql"); + assertThat(sql).contains("'wh_location', 'shared_overflow'") + .contains("'wh_location_reservation', 'shared_overflow'") + .contains("'wh_location_occupancy', 'shared_overflow'") + .contains("reservation_status = 'RESERVED' AND shared_overflow = 0") + .contains("occupancy_status = ''OCCUPIED'' AND shared_overflow = 0") + .contains("occupancy_status = ''OCCUPIED'' THEN 1") + .contains("active_location_occupancy") + .contains("active_pallet_occupancy") + .contains("active_pallet_reservation") + .contains("uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation)") + .contains("uk_location_active_occupancy (tenant_id, location_id, active_location_occupancy)") + .contains("uk_pallet_active_occupancy (tenant_id, pallet_id, active_pallet_occupancy)") + .contains("FROM information_schema.columns") + .doesNotContain("ADD COLUMN IF NOT EXISTS"); + } + @Test void 既有库增量迁移覆盖全部仓储实体表和列表索引() throws IOException { String sql = read("phase1-warehouse-schema-migration.sql"); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationApplicationServiceTest.java new file mode 100644 index 000000000..c8671da6d --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationApplicationServiceTest.java @@ -0,0 +1,99 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.LocationDtos.SaveRequest; +import com.baiyue.warehouse.entity.LocationEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.repository.LocationRepository; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class LocationApplicationServiceTest { + @Test + void 新增时仓库必须属于当前企业租户() { + LocationRepository repository = mock(LocationRepository.class); + LocationApplicationService service = new LocationApplicationService(repository); + + assertThatThrownBy(() -> service.create(10001L, request("AVAILABLE", null))) + .isInstanceOf(BizException.class).hasMessageContaining("企业租户"); + verify(repository, never()).insert(any()); + } + + @Test + void 同一租户仓库的库位编码必须唯一() { + LocationRepository repository = mock(LocationRepository.class); + when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse("ACTIVE")); + when(repository.existsCode(10001L, 88L, "A1-01", null)).thenReturn(true); + LocationApplicationService service = new LocationApplicationService(repository); + + assertThatThrownBy(() -> service.create(10001L, request("AVAILABLE", null))) + .isInstanceOf(BizException.class).hasMessageContaining("编码"); + } + + @Test + void 活动预占或占用库位不能停用() { + LocationRepository repository = mock(LocationRepository.class); + LocationEntity location = location("AVAILABLE", 3); + when(repository.findOwned(10001L, 88L, 9L)).thenReturn(location); + when(repository.hasActiveResource(10001L, 9L)).thenReturn(true); + LocationApplicationService service = new LocationApplicationService(repository); + + assertThatThrownBy(() -> service.changeStatus(10001L, 88L, 9L, "DISABLED", 3, 7L)) + .isInstanceOf(BizException.class).hasMessageContaining("不能停用"); + verify(repository, never()).changeStatus(10001L, 88L, 9L, "AVAILABLE", "DISABLED", 3, 7L); + } + + @Test + void 删除前必须先停用且没有活动资源() { + LocationRepository repository = mock(LocationRepository.class); + when(repository.findOwned(10001L, 88L, 9L)).thenReturn(location("AVAILABLE", 3)); + LocationApplicationService service = new LocationApplicationService(repository); + + assertThatThrownBy(() -> service.delete(10001L, 88L, 9L, 3, 7L)) + .isInstanceOf(BizException.class).hasMessageContaining("先停用"); + verify(repository, never()).deleteDisabled(10001L, 88L, 9L, 3, 7L); + } + + @Test + void 条件更新失败统一转换为乐观锁冲突() { + LocationRepository repository = mock(LocationRepository.class); + when(repository.findOwned(10001L, 88L, 9L)).thenReturn(location("DISABLED", 3)); + when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse("ACTIVE")); + when(repository.changeStatus(10001L, 88L, 9L, "DISABLED", "AVAILABLE", 3, 7L)).thenReturn(0); + LocationApplicationService service = new LocationApplicationService(repository); + + assertThatThrownBy(() -> service.changeStatus(10001L, 88L, 9L, "AVAILABLE", 3, 7L)) + .isInstanceOf(BizException.class).hasMessageContaining("刷新"); + } + + private static SaveRequest request(String status, Integer version) { + return new SaveRequest(88L, "NORMAL", "常规货", "A1-01", "A1 区 01 位", + "PALLET", new BigDecimal("500"), status, "备注", version); + } + + private static WarehouseEntity warehouse(String status) { + WarehouseEntity warehouse = new WarehouseEntity(); + warehouse.setId(88L); + warehouse.setTenantId(10001L); + warehouse.setStatus(status); + return warehouse; + } + + private static LocationEntity location(String status, int version) { + LocationEntity location = new LocationEntity(); + location.setId(9L); + location.setTenantId(10001L); + location.setWarehouseId(88L); + location.setStatus(status); + location.setVersion(version); + return location; + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java new file mode 100644 index 000000000..cce897c87 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java @@ -0,0 +1,202 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.warehouse.dto.LocationDtos.ConfirmCommand; +import com.baiyue.warehouse.dto.LocationDtos.OccupancyTarget; +import com.baiyue.warehouse.dto.LocationDtos.ReservationTarget; +import com.baiyue.warehouse.dto.LocationDtos.ReserveCommand; +import com.baiyue.warehouse.entity.LocationEntity; +import com.baiyue.warehouse.entity.LocationOccupancyEntity; +import com.baiyue.warehouse.entity.LocationReservationEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.repository.LocationRepository; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class LocationResourceServiceTest { + @Test + void 普通库位不足时优先分配普通位并将剩余托盘共享到A0() { + LocationRepository repository = mock(LocationRepository.class); + when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse()); + when(repository.lockAvailable(10001L, 88L, 2)).thenReturn(List.of(location(9L, "A1"))); + when(repository.lockOrCreateSharedOverflow(10001L, 88L, 7L)).thenReturn(overflow()); + when(repository.changeStatus(10001L, 88L, 9L, "AVAILABLE", "RESERVED", 0, 7L)).thenReturn(1); + LocationResourceService service = new LocationResourceService(repository); + + var result = service.reserveAvailable(reserveCommand()); + + assertThat(result).extracting(item -> item.locationCode()).containsExactly("A1", "A0"); + assertThat(result).extracting(item -> item.sharedOverflow()).containsExactly(false, true); + verify(repository).changeStatus(10001L, 88L, 9L, "AVAILABLE", "RESERVED", 0, 7L); + verify(repository, never()).changeStatus(10001L, 88L, 99L, "AVAILABLE", "RESERVED", 0, 7L); + verify(repository, times(2)).insertReservation(any()); + } + + @Test + void 无普通库位时同一请求的所有不足目标复用同一个A0() { + LocationRepository repository = mock(LocationRepository.class); + when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse()); + when(repository.lockAvailable(10001L, 88L, 2)).thenReturn(List.of()); + when(repository.lockOrCreateSharedOverflow(10001L, 88L, 7L)).thenReturn(overflow()); + LocationResourceService service = new LocationResourceService(repository); + + var result = service.reserveAvailable(reserveCommand()); + + assertThat(result).extracting(item -> item.locationId()).containsExactly(99L, 99L); + assertThat(result).extracting(item -> item.locationCode()).containsExactly("A0", "A0"); + verify(repository, never()).changeStatus(anyLong(), anyLong(), anyLong(), + any(String.class), any(String.class), anyInt(), anyLong()); + } + + @Test + void 单事务按托盘顺序锁定并条件预占库位() { + LocationRepository repository = mock(LocationRepository.class); + when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse()); + when(repository.lockAvailable(10001L, 88L, 2)) + .thenReturn(List.of(location(9L, "A1"), location(10L, "A2"))); + when(repository.changeStatus(10001L, 88L, 9L, "AVAILABLE", "RESERVED", 0, 7L)).thenReturn(1); + when(repository.changeStatus(10001L, 88L, 10L, "AVAILABLE", "RESERVED", 0, 7L)).thenReturn(1); + LocationResourceService service = new LocationResourceService(repository); + + var result = service.reserveAvailable(reserveCommand()); + + assertThat(result).extracting(item -> item.locationCode()).containsExactly("A1", "A2"); + ArgumentCaptor captor = ArgumentCaptor.forClass(LocationReservationEntity.class); + verify(repository, org.mockito.Mockito.times(2)).insertReservation(captor.capture()); + assertThat(captor.getAllValues()).extracting(LocationReservationEntity::getIdempotencyKey) + .containsExactly("inbound-1-p1", "inbound-1-p2"); + } + + @Test + void 已确认预占按占用事实幂等返回() { + LocationRepository repository = mock(LocationRepository.class); + when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse()); + LocationReservationEntity reservation = reservation("CONFIRMED"); + when(repository.lockReservation(10001L, 101L)).thenReturn(reservation); + LocationOccupancyEntity occupancy = new LocationOccupancyEntity(); + occupancy.setId(201L); + occupancy.setOccupancyNo("LO201"); + occupancy.setReservationId(101L); + occupancy.setPalletId(501L); + occupancy.setLocationId(9L); + occupancy.setLocationCode("A1"); + when(repository.findOccupancyByReservation(10001L, 101L)).thenReturn(occupancy); + LocationResourceService service = new LocationResourceService(repository); + + var result = service.confirmOccupied(confirmCommand()); + + assertThat(result).singleElement().extracting(item -> item.occupancyId()).isEqualTo(201L); + verify(repository, never()).insertOccupancy(any()); + } + + @Test + void 确认占用使用预占归属和库位版本条件更新() { + LocationRepository repository = mock(LocationRepository.class); + when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse()); + LocationReservationEntity reservation = reservation("RESERVED"); + when(repository.lockReservation(10001L, 101L)).thenReturn(reservation); + when(repository.findOwned(10001L, 88L, 9L)).thenReturn(location(9L, "A1")); + when(repository.changeStatus(10001L, 88L, 9L, "RESERVED", "OCCUPIED", 0, 7L)).thenReturn(1); + when(repository.confirmReservation(reservation)).thenReturn(1); + LocationResourceService service = new LocationResourceService(repository); + + var result = service.confirmOccupied(confirmCommand()); + + assertThat(result).hasSize(1); + verify(repository).insertOccupancy(any(LocationOccupancyEntity.class)); + verify(repository).confirmReservation(reservation); + } + + @Test + void 确认A0占用时保持共享库位AVAILABLE() { + LocationRepository repository = mock(LocationRepository.class); + when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse()); + LocationReservationEntity reservation = reservation("RESERVED"); + reservation.setLocationId(99L); + reservation.setLocationCode("A0"); + reservation.setSharedOverflow(true); + when(repository.lockReservation(10001L, 101L)).thenReturn(reservation); + when(repository.findOwned(10001L, 88L, 99L)).thenReturn(overflow()); + when(repository.confirmReservation(reservation)).thenReturn(1); + LocationResourceService service = new LocationResourceService(repository); + + var result = service.confirmOccupied(confirmCommand()); + + assertThat(result).singleElement().satisfies(item -> { + assertThat(item.locationCode()).isEqualTo("A0"); + assertThat(item.sharedOverflow()).isTrue(); + }); + verify(repository, never()).changeStatus(anyLong(), anyLong(), anyLong(), + any(String.class), any(String.class), anyInt(), anyLong()); + verify(repository).insertOccupancy(any(LocationOccupancyEntity.class)); + } + + private static ReserveCommand reserveCommand() { + return new ReserveCommand(10001L, 88L, "FIRSTLEG", 301L, "RK001", 7L, + LocalDateTime.now().plusMinutes(30), List.of( + new ReservationTarget(501L, "P1", "inbound-1-p1"), + new ReservationTarget(502L, "P2", "inbound-1-p2"))); + } + + private static ConfirmCommand confirmCommand() { + return new ConfirmCommand(10001L, 88L, "FIRSTLEG", 301L, 7L, + List.of(new OccupancyTarget(101L, new BigDecimal("1.200000"), 10))); + } + + private static WarehouseEntity warehouse() { + WarehouseEntity warehouse = new WarehouseEntity(); + warehouse.setId(88L); + warehouse.setTenantId(10001L); + warehouse.setStatus("ACTIVE"); + return warehouse; + } + + private static LocationEntity location(long id, String code) { + LocationEntity location = new LocationEntity(); + location.setId(id); + location.setTenantId(10001L); + location.setWarehouseId(88L); + location.setLocationCode(code); + location.setStatus("AVAILABLE"); + location.setSharedOverflow(false); + location.setVersion(0); + return location; + } + + private static LocationEntity overflow() { + LocationEntity location = location(99L, "A0"); + location.setSharedOverflow(true); + return location; + } + + private static LocationReservationEntity reservation(String status) { + LocationReservationEntity reservation = new LocationReservationEntity(); + reservation.setId(101L); + reservation.setTenantId(10001L); + reservation.setWarehouseId(88L); + reservation.setSourceService("FIRSTLEG"); + reservation.setBusinessId(301L); + reservation.setInboundCode("RK001"); + reservation.setPalletId(501L); + reservation.setPalletCode("P1"); + reservation.setLocationId(9L); + reservation.setLocationCode("A1"); + reservation.setReservationStatus(status); + reservation.setExpiresAt(LocalDateTime.now().plusMinutes(10)); + reservation.setVersion(0); + return reservation; + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java new file mode 100644 index 000000000..03233e202 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java @@ -0,0 +1,40 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.entity.PalletEntity; +import com.baiyue.warehouse.repository.PalletRepository; +import com.baiyue.warehouse.repository.WarehouseRepository; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class PalletApplicationServiceTest { + private final PalletRepository repository = mock(PalletRepository.class); + private final WarehouseRepository warehouseRepository = mock(WarehouseRepository.class); + private final PalletApplicationService service = new PalletApplicationService(repository, warehouseRepository); + + @Test + void 平台详情不暴露内部状态() { + PalletEntity entity = pallet("OCCUPIED"); + when(repository.findOwned(10001, 20, 30)).thenReturn(entity); + org.assertj.core.api.Assertions.assertThat(service.detail(10001, 20, 30)) + .extracting("id", "palletCode").containsExactly(30L, null); + } + + @Test + void 跨仓库托盘不可见() { + assertThatThrownBy(() -> service.detail(10001, 21, 30)).isInstanceOf(BizException.class); + } + + private static PalletEntity pallet(String status) { + PalletEntity entity = new PalletEntity(); + entity.setId(30L); + entity.setTenantId(10001L); + entity.setWarehouseId(20L); + entity.setStatus(status); + entity.setVersion(0); + return entity; + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java new file mode 100644 index 000000000..c60348d62 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java @@ -0,0 +1,108 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.LocationDtos; +import com.baiyue.warehouse.dto.PalletAllocationDtos; +import com.baiyue.warehouse.entity.PalletEntity; +import com.baiyue.warehouse.repository.PalletRepository; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PalletLocationAllocationServiceTest { + private final PalletRepository pallets = mock(PalletRepository.class); + private final LocationResourceService locations = mock(LocationResourceService.class); + private final PalletLocationAllocationService service = + new PalletLocationAllocationService(pallets, new PalletVolumeSplitter(), locations); + + @Test + void 并发抢占托盘时整批失败且不预占库位() { + when(locations.findActiveResources(10001, "FIRSTLEG", 88)).thenReturn(List.of()); + when(pallets.findAvailable(10001, 20, "木托盘", new BigDecimal("2"), 1)) + .thenReturn(List.of(pallet(30, "TP-001"))); + when(pallets.transitionStatus(10001, 20, 30, List.of("AVAILABLE"), "RESERVED")).thenReturn(0); + + assertThatThrownBy(() -> service.reserve(reserve(88))).isInstanceOf(BizException.class); + verify(locations, never()).reserveAvailable(any()); + } + + @Test + void 未指定类型时默认推荐普通托盘() { + when(locations.findActiveResources(10001, "FIRSTLEG", 99)).thenReturn(List.of()); + when(pallets.findAvailable(10001, 20, "STANDARD", new BigDecimal("2"), 1)) + .thenReturn(List.of(pallet(30, "TP-001"))); + when(pallets.transitionStatus(10001, 20, 30, List.of("AVAILABLE"), "RESERVED")).thenReturn(1); + when(locations.reserveAvailable(any())).thenReturn(List.of( + new LocationDtos.ReservationResult(101, "LR101", 30, "TP-001", 40, "A0", true, + LocalDateTime.now().plusMinutes(30)))); + + var request = new PalletAllocationDtos.ReserveRequest(10001, 20, "FIRSTLEG", 99, "RK-001", "measure-99", + 9001, new BigDecimal("2"), new BigDecimal("2"), null, LocalDateTime.now().plusMinutes(30), true); + var result = service.reserve(request); + + assertThat(result.allocations().get(0).locationCode()).isEqualTo("A0"); + verify(pallets).findAvailable(10001, 20, "STANDARD", new BigDecimal("2"), 1); + } + + @Test + void 释放预占后托盘恢复可用并可再次预占() { + LocationDtos.ActiveResource resource = new LocationDtos.ActiveResource( + 101, null, 20, 30, "TP-001", 40, "A-01", false, "RESERVED"); + when(locations.findActiveResources(10001, "FIRSTLEG", 88)).thenReturn(List.of(resource)); + when(locations.release(any())).thenReturn(List.of(resource)); + when(pallets.transitionStatus(10001, 20, 30, List.of("RESERVED"), "AVAILABLE")).thenReturn(1); + + List released = service.release( + new PalletAllocationDtos.ReleaseRequest(10001, "FIRSTLEG", 88, 9001, "入库补偿")); + + assertThat(released).containsExactly(resource); + verify(pallets).transitionStatus(10001, 20, 30, List.of("RESERVED"), "AVAILABLE"); + } + + @Test + void 已确认占用禁止补偿释放避免回滚入库事实() { + LocationDtos.ActiveResource resource = new LocationDtos.ActiveResource( + 101, 201L, 20, 30, "TP-001", 40, "A-01", false, "OCCUPIED"); + when(locations.findActiveResources(10001, "FIRSTLEG", 88)).thenReturn(List.of(resource)); + + assertThatThrownBy(() -> service.release( + new PalletAllocationDtos.ReleaseRequest(10001, "FIRSTLEG", 88, 9001, "错误补偿"))) + .isInstanceOf(BizException.class); + verify(locations, never()).release(any()); + } + + @Test + void 相同业务幂等重试直接返回已有预占() { + LocationDtos.ActiveResource resource = new LocationDtos.ActiveResource( + 101, null, 20, 30, "TP-001", 40, "A-01", false, "RESERVED"); + when(locations.findActiveResources(10001, "FIRSTLEG", 88)).thenReturn(List.of(resource)); + + var result = service.reserve(reserve(88)); + + assertThat(result.palletCount()).isEqualTo(1); + assertThat(result.allocations().get(0).state()).isEqualTo("RESERVED"); + verify(pallets, never()).findAvailable(any(Long.class), any(Long.class), any(), any(), any(Integer.class)); + } + + private static PalletAllocationDtos.ReserveRequest reserve(long businessId) { + return new PalletAllocationDtos.ReserveRequest(10001, 20, "FIRSTLEG", businessId, "RK-001", "measure-001", + 9001, new BigDecimal("2"), new BigDecimal("2"), "木托盘", LocalDateTime.now().plusMinutes(30), true); + } + + private static PalletEntity pallet(long id, String code) { + PalletEntity entity = new PalletEntity(); + entity.setId(id); + entity.setPalletCode(code); + return entity; + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletVolumeSplitterTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletVolumeSplitterTest.java new file mode 100644 index 000000000..41c798f13 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletVolumeSplitterTest.java @@ -0,0 +1,37 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PalletVolumeSplitterTest { + private final PalletVolumeSplitter splitter = new PalletVolumeSplitter(); + + @Test + void 整除时不多拆托盘() { + var result = splitter.split(new BigDecimal("20"), new BigDecimal("2")); + assertThat(result.palletCount()).isEqualTo(10); + assertThat(result.allocations()).allMatch(item -> item.allocatedVolumeCbm().compareTo(new BigDecimal("2")) == 0); + } + + @Test + void 非整除时向上拆分并保留尾盘体积() { + var result = splitter.split(new BigDecimal("20.1"), new BigDecimal("2")); + assertThat(result.palletCount()).isEqualTo(11); + assertThat(result.allocations().get(10).allocatedVolumeCbm()).isEqualByComparingTo("0.1"); + assertThat(result.allocations()).extracting(PalletVolumeSplitter.Allocation::allocationNo) + .containsExactly(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11); + } + + @Test + void 拒绝空值零值负值和超大拆分() { + assertThatThrownBy(() -> splitter.split(null, BigDecimal.ONE)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> splitter.split(BigDecimal.ZERO, BigDecimal.ONE)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> splitter.split(BigDecimal.ONE, BigDecimal.ZERO)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> splitter.split(new BigDecimal("10001"), BigDecimal.ONE)).isInstanceOf(BizException.class); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessServiceTest.java index e160433d2..3fb2c64a2 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessServiceTest.java @@ -22,6 +22,9 @@ class WarehouseSchemaReadinessServiceTest { assertThat(readiness.ready()).isTrue(); assertThat(readiness.status()).isEqualTo("UP"); verify(jdbcTemplate).execute(org.mockito.ArgumentMatchers.contains("wh_express_company")); + verify(jdbcTemplate).execute(org.mockito.ArgumentMatchers.contains("wh_location_reservation")); + verify(jdbcTemplate).execute(org.mockito.ArgumentMatchers.contains("wh_location_occupancy")); + verify(jdbcTemplate).execute(org.mockito.ArgumentMatchers.contains("active_location_occupancy")); } @Test