Merge test fixes into dev dropship flow
This commit is contained in:
@@ -20,7 +20,7 @@
|
||||
|---|---|---|
|
||||
| 登录、Token、密码、注册与顶层授权快照 | `auth/` | `auth/AGENTS.md`;海外 Token 只携带稳定身份与版本,角色、权限和冻结海外仓从登录/刷新响应顶层快照读取,海外 Gateway 再按 IAM 安全投影失败关闭校验 |
|
||||
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
|
||||
| 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 |
|
||||
| 客户建档默认箱规与权重系数初始化及补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults` 在同一事务生成四档尺寸和默认权重系数规则,未全部完成不得标记 `READY` 或审核通过 |
|
||||
| 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 |
|
||||
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md`;统一列表由 `UnifiedOrderQueryService` 组合交易与履约投影,仍固定显示 `PENDING_REVIEW` 并提供审核动作:Order 为 `SUBMITTED` 时必须优先返回 `PENDING_REVIEW/APPROVE_ORDER`,不得被 Firstleg 准备中状态覆盖 |
|
||||
| 客户头程订单中文批量导入 | `order/` | `MerchantBatchImportService.java`、`MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`、`order-v1.yaml`;模板和导入都按当前客户读取动态地址、全部有效仓库与渠道,最多 50 行,只创建草稿,支持 `PARTIAL/ATOMIC` |
|
||||
@@ -95,6 +95,8 @@
|
||||
|
||||
## 契约、数据库与验证入口
|
||||
|
||||
- 对外 Controller/API 新增、改名或删除时,同时核对调用端、区域 Gateway、OpenAPI 和权限;提交前运行 `node --test scripts/gateway-route-contract.test.mjs`,自动发现生产 Controller 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。
|
||||
|
||||
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
|
||||
- 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。
|
||||
- 所有生产 MyBatis Mapper 接口必须显式标记 `@Mapper`;每个持久化服务只允许在启动类使用一次服务根包 `@MapperScan(..., annotationClass = Mapper.class)`,不得新增子域包白名单或分散的 `*MapperConfiguration`。新增或移动 Mapper 后先运行 `node scripts/mapper-scan-contract.mjs`,全量后端回归也会在 Maven 前执行该门禁。
|
||||
|
||||
Vendored
+10
@@ -316,6 +316,16 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
stage('Gateway 路由静态门禁') {
|
||||
when {
|
||||
expression { env.NO_SERVICE_CHANGE != 'true' }
|
||||
}
|
||||
agent { label 'java-builder' }
|
||||
steps {
|
||||
sh 'node --test scripts/gateway-route-contract.test.mjs'
|
||||
}
|
||||
}
|
||||
|
||||
stage('统一 Maven 测试门禁') {
|
||||
when {
|
||||
expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS }
|
||||
|
||||
+7
-2
@@ -28,8 +28,13 @@ public record FirstLegContainerShippingNodeRecordedV1(
|
||||
"EMPTY_RETURNED", 100);
|
||||
|
||||
public FirstLegContainerShippingNodeRecordedV1 {
|
||||
if (queryId == null || queryId.isBlank() || oceanBookingId == null || oceanBookingId.isBlank()
|
||||
|| containerBookingId == null || containerBookingId.isBlank()
|
||||
if (queryId != null && queryId.isBlank()) {
|
||||
throw new IllegalArgumentException("船节点跟踪任务标识不能为空字符串");
|
||||
}
|
||||
if (oceanBookingId != null && oceanBookingId.isBlank()) {
|
||||
throw new IllegalArgumentException("船节点订舱标识不能为空字符串");
|
||||
}
|
||||
if (containerBookingId == null || containerBookingId.isBlank()
|
||||
|| containerNo == null || containerNo.isBlank()) {
|
||||
throw new IllegalArgumentException("船节点交接业务标识不能为空");
|
||||
}
|
||||
|
||||
@@ -42,11 +42,10 @@ components:
|
||||
destinationType: {type: string, enum: [FBA, OVERSEAS]}
|
||||
FirstLegContainerShippingNodeRecordedV1:
|
||||
type: object
|
||||
required: [queryId, customerId, oceanBookingId, sourceContainerBookingId, containerNo, standardNode, nodeSequence, observedAt, actualAt, providerCode, providerEventId, providerNodeCode, rawPayloadHash]
|
||||
required: [sourceContainerBookingId, containerNo, standardNode, nodeSequence, observedAt, actualAt, providerCode, providerEventId, providerNodeCode, rawPayloadHash]
|
||||
properties:
|
||||
queryId: {type: string}
|
||||
customerId: {type: string}
|
||||
oceanBookingId: {type: string}
|
||||
queryId: {type: [string, 'null'], description: Automatic tracking task ID; null for a direct platform manual node.}
|
||||
oceanBookingId: {type: [string, 'null'], description: Ocean booking ID when the physical container has already been linked; null is valid for a direct manual node.}
|
||||
sourceContainerBookingId: {type: string, minLength: 1}
|
||||
containerNo: {type: string, minLength: 1, description: Uppercase normalized physical container number; never cabinetNo or a technical identity}
|
||||
standardNode: {type: string}
|
||||
|
||||
+13
@@ -35,6 +35,19 @@ class FirstLegContainerShippingNodeRecordedV1Test {
|
||||
.hasMessageContaining("normalized");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsDirectManualNodeWithoutTrackingQuery() {
|
||||
var payload = payload();
|
||||
var manual = new FirstLegContainerShippingNodeRecordedV1(
|
||||
null, null, payload.containerBookingId(), payload.containerNo(),
|
||||
payload.standardNode(), payload.nodeSequence(), payload.observedAt(), payload.actualAt(),
|
||||
payload.locationCode(), payload.locationName(), "MANUAL", "manual-event", payload.providerNodeCode(),
|
||||
"MANUAL:7", payload.rawPayloadHash());
|
||||
|
||||
assertThat(manual.queryId()).isNull();
|
||||
assertThat(manual.oceanBookingId()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEstimatedOnlyOrIncompleteProviderFacts() {
|
||||
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
|
||||
|
||||
+11
-3
@@ -1,8 +1,9 @@
|
||||
# 测试环境 SQL 执行说明
|
||||
|
||||
## 唯一数据库初始化与发布入口
|
||||
## 数据库初始化与权限发布入口
|
||||
|
||||
- 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。
|
||||
- 国内区域全量入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境升级,不再按日期拆分结构发布脚本。
|
||||
- 通用权限点独立入口:`权限点初始化-MySQL8.sql`。国内与海外业务的 Admin/Client 权限主数据均在 `baiyue_iam` 统一管理。当发布内容只新增或更新 IAM 权限资源及默认角色授权,且 IAM 基础表、父菜单和系统角色已存在时,可只执行该文件。它不建表,不创建账号、客户、仓库或业务数据。
|
||||
- 海外区域固定入口:`海外区域统一增量-MySQL8.sql`。该文件已经内联 Lastleg 与 Warehouse Overseas 的完整空库基线,会自行创建并初始化 `baiyue_lastleg`、`baiyue_lastlag_warehouse` 和独立的 `baiyue_event_bridge_overseas`,清库后不得再额外执行模块基线。IAM 安全事件 Inbox 和可恢复投影位于 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。
|
||||
- Lastleg 基线和海外固定增量仅确定性初始化国内固定 `platform_admin`(`authUserId=1000001`、`tenantId=0`)的 USER/AUTHORIZATION version=0 安全投影;AUTHORIZATION 同步冻结当前 `PLATFORM_ADMIN` 角色及 IAM 跨区前缀下的完整权限集合,保证空库重建后海外 Gateway 可立即鉴权。统一 SQL 重放只修正仍处于固定 baseline version=0 的该行,其他既有开发账号不建立兼容表或长期补投入口。
|
||||
- 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。
|
||||
@@ -25,6 +26,13 @@
|
||||
4. 每个增量区块末尾增加只读断言,并同步维护统一脚本末尾的总体验证。
|
||||
5. MySQL DDL 会隐式提交,执行前仍应在目标环境确认连接和授权;不能把跨库脚本视为可整体回滚事务。
|
||||
|
||||
### 权限点独立入口边界
|
||||
|
||||
- 新增权限时,同步维护 IAM 模块来源、`权限点初始化-MySQL8.sql`、对应区域的全量统一 SQL、前端按钮/路由、Controller `@PreAuthorize` 和 OpenAPI。
|
||||
- 权限点入口只用于既有 IAM 基线上的权限新增或元数据/默认授权更新。涉及建表、字段、索引、角色创建或其他业务数据时,仍执行全量统一 SQL。
|
||||
- 独立入口必须可重复执行,执行前校验稳定权限 ID/权限码没有被其他语义占用,执行后只在授权完整时返回 `permission_initialization_status = PASS`。
|
||||
- 权限写入后不修改 Redis 中已签发的会话快照;受影响用户必须退出并重新登录。权限撤销不通过该 SQL 直接处理,应走 IAM 角色授权命令以同步撤销会话。
|
||||
|
||||
## Finance 首期人工财务基线
|
||||
|
||||
- Finance 数据库为 `baiyue_finance`,模块溯源脚本为 `finance/src/main/resources/db/finance.sql`,测试和存量环境仍只执行统一入口。
|
||||
@@ -53,7 +61,7 @@
|
||||
|
||||
固定入口同时幂等补齐审核自动开户所需的全局 `MERCHANT_OWNER` 角色模板、全局商户菜单/按钮权限和角色授权;执行后应使用 `iam/src/test/resources/db/verify-phase1-admin-rbac.sql` 确认模板存在且权限完整。
|
||||
|
||||
测试环境只执行当前固定入口;历史脚本和模块脚本不应在固定入口执行后再次串行执行。
|
||||
测试环境结构发布只执行全量固定入口;纯权限点发布可改用权限点独立入口。历史脚本和模块脚本不应在任一发布入口执行后再次串行执行。
|
||||
|
||||
## 执行方式
|
||||
|
||||
|
||||
+507
-161
@@ -1345,18 +1345,19 @@ VALUES
|
||||
(5102019, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:create', '平台代客创建订单', NULL, NULL, 90, 'ACTIVE', 0),
|
||||
(5102024, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:edit', '平台编辑订单草稿', NULL, NULL, 100, 'ACTIVE', 0),
|
||||
(5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 0),
|
||||
(5102026, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:export', '导出订单列表', NULL, NULL, 120, 'ACTIVE', 0)
|
||||
(5102026, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:export', '导出订单列表', NULL, NULL, 120, 'ACTIVE', 0),
|
||||
(5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit', '修改订单申报与运营预估', NULL, NULL, 130, 'ACTIVE', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), 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 (5102019, 5102024, 5102025, 5102026)
|
||||
WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)
|
||||
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 (5102019, 5102024, 5102025, 5102026)
|
||||
WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)
|
||||
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
|
||||
|
||||
INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES
|
||||
@@ -3053,6 +3054,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule (
|
||||
effective_to DATETIME(3) NULL COMMENT '生效结束时间',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态:ACTIVE启用、DISABLED停用',
|
||||
remark VARCHAR(500) NULL COMMENT '规则备注',
|
||||
initialization_key VARCHAR(128) 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',
|
||||
@@ -3061,6 +3063,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule (
|
||||
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_initialization (tenant_id, initialization_key),
|
||||
KEY idx_charge_rule_match (tenant_id, customer_id, business_type, status, effective_from, effective_to)
|
||||
) ENGINE=InnoDB COMMENT='客户头程计费规则版本';
|
||||
|
||||
@@ -3717,6 +3720,7 @@ CALL phase2_fba_add_column('fl_customer_charge_rule', 'length_factor', "DECIMAL(
|
||||
CALL phase2_fba_add_column('fl_customer_charge_rule', 'width_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '宽度计算系数' AFTER length_factor");
|
||||
CALL phase2_fba_add_column('fl_customer_charge_rule', 'height_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '高度计算系数' AFTER width_factor");
|
||||
CALL phase2_fba_add_column('fl_customer_charge_rule', 'default_rule', "TINYINT(1) NOT NULL DEFAULT 0 COMMENT '客户默认生效规则' AFTER status");
|
||||
CALL phase2_fba_add_column('fl_customer_charge_rule', 'initialization_key', "VARCHAR(128) NULL COMMENT '客户创建执行器幂等键及补偿归属' AFTER remark");
|
||||
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', 'measurement_id', "BIGINT UNSIGNED NULL COMMENT '费用绑定的测量批次ID快照' AFTER inbound_id");
|
||||
@@ -3760,6 +3764,14 @@ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
DROP PROCEDURE IF EXISTS phase2_fba_add_column;
|
||||
|
||||
SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_customer_charge_rule'
|
||||
AND INDEX_NAME = 'idx_charge_rule_initialization');
|
||||
SET @sql = IF(@idx_exists = 0,
|
||||
'ALTER TABLE fl_customer_charge_rule ADD INDEX idx_charge_rule_initialization (tenant_id, initialization_key)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_price_channel_catalog (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '渠道目录ID',
|
||||
channel_code VARCHAR(64) NOT NULL COMMENT '稳定渠道编码',
|
||||
@@ -5005,7 +5017,6 @@ CREATE TABLE IF NOT EXISTS sys_audit_dead_letter (
|
||||
-- ============================================================
|
||||
-- 三期智能装柜、预装冻结与装柜清单
|
||||
-- 来源:firstleg/src/main/resources/db/phase3-smart-loading-schema.sql
|
||||
-- ============================================================
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS baiyue_firstleg
|
||||
@@ -5060,7 +5071,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
|
||||
vessel_name VARCHAR(128) NULL COMMENT '船名快照',
|
||||
voyage_no VARCHAR(64) NULL COMMENT '航次;确认前允许修订',
|
||||
container_type VARCHAR(32) NOT NULL COMMENT '箱型,例如20GP、40GP、40HQ',
|
||||
container_type VARCHAR(32) NOT NULL COMMENT '平台固定柜型:20GP、40GP、40HC、40NR、45HC',
|
||||
shipping_company_id BIGINT UNSIGNED NULL COMMENT '船司ID快照',
|
||||
shipping_company_name VARCHAR(128) NULL COMMENT '船司名称快照',
|
||||
departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照',
|
||||
@@ -5080,6 +5091,11 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
actual_port_arrival_at DATETIME(3) NULL COMMENT '实际到港时间',
|
||||
estimated_warehouse_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间',
|
||||
actual_warehouse_arrival_at DATETIME(3) NULL COMMENT '实际到仓时间',
|
||||
unloading_completed_at DATETIME(3) NULL COMMENT '海外仓卸柜完成时间(UTC)',
|
||||
unloading_photo_uploaded_at DATETIME(3) NULL COMMENT '海外仓卸柜照片上传成功时间(UTC)',
|
||||
estimated_empty_reported_at DATETIME(3) NULL COMMENT '预计还空时间(卸柜完成时间加72小时,UTC)',
|
||||
actual_empty_reported_at DATETIME(3) NULL COMMENT '实际报空时间(UTC)',
|
||||
empty_report_source_version BIGINT UNSIGNED NULL COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖',
|
||||
pickup_code VARCHAR(64) NULL COMMENT 'PU数字码;按字符串保存以保留前导零',
|
||||
pickup_code_updated_by BIGINT UNSIGNED NULL COMMENT 'PU码最后填写人ID',
|
||||
pickup_code_updated_by_name VARCHAR(64) NULL COMMENT 'PU码最后填写人名称快照',
|
||||
@@ -5089,8 +5105,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
|
||||
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
|
||||
COMMENT '货柜资料最后更新时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜资料最后更新时间',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_container_booking_no (booking_no),
|
||||
@@ -5099,17 +5114,31 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
KEY idx_container_booking_merge (record_status,merged_into_container_booking_id),
|
||||
KEY idx_container_booking_warehouse (warehouse_id, updated_at, id),
|
||||
KEY idx_container_booking_voyage (voyage_no, updated_at),
|
||||
KEY idx_container_booking_transit_summary (actual_sailing_at, actual_warehouse_arrival_at, ocean_booking_id, container_type),
|
||||
CONSTRAINT chk_container_booking_source CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING')),
|
||||
CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')),
|
||||
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
|
||||
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0),
|
||||
CONSTRAINT chk_container_booking_pickup_code CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$'),
|
||||
CONSTRAINT chk_container_booking_iso6346 CHECK
|
||||
(container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$'),
|
||||
CONSTRAINT chk_container_booking_pickup_code CHECK
|
||||
(pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$')
|
||||
(container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$')
|
||||
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_container_event_inbox (
|
||||
event_id VARCHAR(64) NOT NULL COMMENT '跨区事件全局唯一ID',
|
||||
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
|
||||
event_version INT UNSIGNED NOT NULL COMMENT '事件契约版本',
|
||||
aggregate_id VARCHAR(64) NOT NULL COMMENT '跨区业务聚合ID',
|
||||
aggregate_version BIGINT UNSIGNED NOT NULL COMMENT '跨区业务聚合版本',
|
||||
payload_hash VARCHAR(80) NOT NULL COMMENT '规范化负载SHA-256摘要',
|
||||
payload_json JSON NOT NULL COMMENT '原始事件负载',
|
||||
status VARCHAR(16) NOT NULL COMMENT 'RECEIVED或PROCESSED',
|
||||
received_at DATETIME(3) NOT NULL COMMENT '接收时间(UTC)',
|
||||
processed_at DATETIME(3) NULL COMMENT '处理完成时间(UTC)',
|
||||
PRIMARY KEY (event_id),
|
||||
KEY idx_container_event_aggregate (aggregate_id, aggregate_version)
|
||||
) ENGINE=InnoDB COMMENT='海外到国内业务事件消费Inbox';
|
||||
|
||||
DROP PROCEDURE IF EXISTS phase3_container_real_unit_columns;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE phase3_container_real_unit_columns()
|
||||
@@ -5139,10 +5168,40 @@ BEGIN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN customer_name VARCHAR(128) NULL
|
||||
COMMENT '已关联真实订舱客户名称快照' AFTER customer_id;
|
||||
END IF;
|
||||
UPDATE fl_container_booking container_booking
|
||||
JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id
|
||||
SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id),
|
||||
container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name)
|
||||
WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='unloading_completed_at') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN unloading_completed_at DATETIME(3) NULL
|
||||
COMMENT '海外仓卸柜完成时间(UTC)' AFTER actual_warehouse_arrival_at;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='unloading_photo_uploaded_at') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN unloading_photo_uploaded_at DATETIME(3) NULL
|
||||
COMMENT '海外仓卸柜照片上传成功时间(UTC)' AFTER unloading_completed_at;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='estimated_empty_reported_at') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN estimated_empty_reported_at DATETIME(3) NULL
|
||||
COMMENT '预计还空时间(卸柜完成时间加72小时,UTC)' AFTER unloading_photo_uploaded_at;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='actual_empty_reported_at') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN actual_empty_reported_at DATETIME(3) NULL
|
||||
COMMENT '实际报空时间(UTC)' AFTER estimated_empty_reported_at;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='empty_report_source_version') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN empty_report_source_version BIGINT UNSIGNED NULL
|
||||
COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖' AFTER actual_empty_reported_at;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='pickup_code') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN pickup_code VARCHAR(64) NULL
|
||||
COMMENT 'PU数字码;按字符串保存以保留前导零' AFTER actual_warehouse_arrival_at;
|
||||
COMMENT 'PU数字码;按字符串保存以保留前导零' AFTER empty_report_source_version;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='pickup_code_updated_by') THEN
|
||||
@@ -5159,14 +5218,6 @@ BEGIN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN pickup_code_updated_at DATETIME(3) NULL
|
||||
COMMENT 'PU码最后填写时间' AFTER pickup_code_updated_by_name;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_ocean_booking') THEN
|
||||
UPDATE fl_container_booking container_booking
|
||||
JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id
|
||||
SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id),
|
||||
container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name)
|
||||
WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND index_name='uk_container_booking_container_no') THEN
|
||||
ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (container_no);
|
||||
@@ -5220,8 +5271,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID(雪花ID)',
|
||||
plan_no VARCHAR(40) NOT NULL COMMENT '平台唯一装柜计划号',
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
|
||||
plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING'
|
||||
COMMENT '装柜计划状态:EDITING编辑中、LOADING装柜中、COMPLETED装柜完成;建议须经人工确认',
|
||||
plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING' COMMENT '装柜计划状态:EDITING编辑中、LOADING装柜中、COMPLETED装柜完成;建议须经人工确认',
|
||||
preload_completed_by BIGINT UNSIGNED NULL COMMENT '预装完成操作人ID',
|
||||
preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照',
|
||||
preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间',
|
||||
@@ -5234,8 +5284,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan (
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
|
||||
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
|
||||
COMMENT '装柜计划最后更新时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜计划最后更新时间',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_loading_plan_no (plan_no),
|
||||
@@ -5347,6 +5396,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pallet_label (
|
||||
CONSTRAINT chk_loading_pallet_index CHECK (pallet_index BETWEEN 1 AND 999)
|
||||
) ENGINE=InnoDB COMMENT='散货按柜号和FBA仓冻结的可打印托盘码';
|
||||
|
||||
-- 柜次级简单文件仅承载目的港清关和提货单;PU资料作为数字码保存在货柜主表。
|
||||
CREATE TABLE IF NOT EXISTS fl_container_document (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
|
||||
@@ -5361,8 +5411,7 @@ CREATE TABLE IF NOT EXISTS fl_container_document (
|
||||
completed_by_name VARCHAR(64) 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 '货柜文件关联最后更新时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜文件关联最后更新时间',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_container_document_type (container_booking_id, document_type),
|
||||
@@ -5373,13 +5422,42 @@ CREATE TABLE IF NOT EXISTS fl_container_document (
|
||||
CHECK (document_type IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER'))
|
||||
) ENGINE=InnoDB COMMENT='平台货柜清关与DO文件关联';
|
||||
|
||||
SET @has_container_document_type_check=(SELECT COUNT(*) FROM information_schema.table_constraints
|
||||
WHERE table_schema=DATABASE() AND table_name='fl_container_document'
|
||||
AND constraint_name='chk_container_document_type' AND constraint_type='CHECK');
|
||||
SET @ddl=IF(@has_container_document_type_check=0,'SELECT 1',
|
||||
'ALTER TABLE fl_container_document DROP CHECK chk_container_document_type');
|
||||
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
|
||||
ALTER TABLE fl_container_document ADD CONSTRAINT chk_container_document_type
|
||||
CHECK (document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER'));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_container_exception_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜异常历史ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '物理货柜ID',
|
||||
action VARCHAR(16) NOT NULL COMMENT '操作类型:MARK标记异常、CANCEL取消异常', reason VARCHAR(500) NOT NULL COMMENT '异常原因',
|
||||
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
|
||||
operation_id VARCHAR(128) NOT NULL COMMENT '同一操作人幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要',
|
||||
occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作发生时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_container_exception_operation(container_booking_id,operator_id,operation_id),
|
||||
KEY idx_container_exception_history(container_booking_id,occurred_at,id),
|
||||
CONSTRAINT fk_container_exception_booking FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
|
||||
CONSTRAINT chk_container_exception_action CHECK(action IN ('MARK','CANCEL'))
|
||||
) ENGINE=InnoDB COMMENT='货柜异常标记及取消原因历史';
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_container_exception_history_before_update;
|
||||
DROP TRIGGER IF EXISTS trg_container_exception_history_before_delete;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_container_exception_history_before_update BEFORE UPDATE ON fl_container_exception_history FOR EACH ROW
|
||||
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜异常历史不可修改'; END$$
|
||||
CREATE TRIGGER trg_container_exception_history_before_delete BEFORE DELETE ON fl_container_exception_history FOR EACH ROW
|
||||
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜异常历史不可删除'; END$$
|
||||
DELIMITER ;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_plan_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '装柜操作历史ID(雪花ID)',
|
||||
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID',
|
||||
loading_plan_item_id BIGINT UNSIGNED NULL COMMENT '装柜计划明细ID',
|
||||
operation_id VARCHAR(128) NOT NULL COMMENT '操作幂等ID',
|
||||
action_type VARCHAR(32) NOT NULL
|
||||
COMMENT '动作类型:CREATE_PLAN创建计划、ADD_ITEM新增明细、ADJUST_ITEM调整明细、REMOVE_ITEM移除明细、START_LOADING开始装柜、COMPLETE_LOADING完成装柜、PICK_ITEM拣货',
|
||||
action_type VARCHAR(32) NOT NULL COMMENT '动作类型:CREATE_PLAN创建计划、ADD_ITEM新增明细、ADJUST_ITEM调整明细、REMOVE_ITEM移除明细、START_LOADING开始装柜、COMPLETE_LOADING完成装柜、PICK_ITEM拣货',
|
||||
from_status VARCHAR(24) NULL COMMENT '变更前状态',
|
||||
to_status VARCHAR(24) NULL COMMENT '变更后状态',
|
||||
before_snapshot JSON NULL COMMENT '变更前快照',
|
||||
@@ -5399,26 +5477,27 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_history (
|
||||
|
||||
-- fl_loading_pick_task 曾被另一套入库汇总模型复用。先按字段签名识别并迁走,
|
||||
-- 再创建智能装柜任务表;未知或混合结构必须人工处理,禁止静默套用错误表型。
|
||||
DROP PROCEDURE IF EXISTS assert_loading_pick_task_shapes;
|
||||
DROP PROCEDURE IF EXISTS migrate_legacy_loading_pick_task;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_loading_pick_task_shapes(IN require_both TINYINT)
|
||||
CREATE PROCEDURE migrate_legacy_loading_pick_task()
|
||||
BEGIN
|
||||
DECLARE source_exists INT DEFAULT 0;
|
||||
DECLARE source_total_columns INT DEFAULT 0;
|
||||
DECLARE source_smart_columns INT DEFAULT 0;
|
||||
DECLARE source_inbound_columns INT DEFAULT 0;
|
||||
DECLARE target_exists INT DEFAULT 0;
|
||||
DECLARE source_total INT DEFAULT 0;
|
||||
DECLARE source_smart INT DEFAULT 0;
|
||||
DECLARE source_stable_smart INT DEFAULT 0;
|
||||
DECLARE source_inbound INT DEFAULT 0;
|
||||
DECLARE target_total INT DEFAULT 0;
|
||||
DECLARE target_inbound INT DEFAULT 0;
|
||||
SELECT COUNT(*) INTO source_exists FROM information_schema.tables
|
||||
DECLARE target_total_columns INT DEFAULT 0;
|
||||
DECLARE target_inbound_columns INT DEFAULT 0;
|
||||
|
||||
SELECT COUNT(*) INTO source_exists
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task';
|
||||
SELECT COUNT(*) INTO target_exists FROM information_schema.tables
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task';
|
||||
IF source_exists = 1 THEN
|
||||
SELECT COUNT(*) INTO source_total FROM information_schema.columns
|
||||
SELECT COUNT(*) INTO source_total_columns
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task';
|
||||
SELECT COUNT(*) INTO source_smart FROM information_schema.columns
|
||||
SELECT COUNT(*) INTO source_smart_columns
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND (
|
||||
(column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'loading_plan_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
@@ -5430,113 +5509,57 @@ BEGIN
|
||||
OR (column_name = 'completed_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'YES')
|
||||
OR (column_name = 'completed_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'completed_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'YES')
|
||||
OR (column_name = 'version' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0'));
|
||||
SELECT COUNT(*) INTO source_inbound FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND (
|
||||
(column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'tenant_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'inbound_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'PENDING')
|
||||
OR (column_name = 'version' AND LOWER(column_type) = 'int' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0')
|
||||
OR (column_name = 'expected_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES')
|
||||
OR (column_name = 'picked_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES')
|
||||
OR (column_name = 'remark' AND LOWER(column_type) = 'varchar(1000)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'updated_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'updated_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'created_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%')
|
||||
OR (column_name = 'updated_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%' AND LOWER(extra) LIKE '%on update current_timestamp%'));
|
||||
SELECT COUNT(*) INTO source_stable_smart FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND (
|
||||
(column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'loading_plan_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'container_booking_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'cabinet_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'NO')
|
||||
OR (column_name = 'container_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'task_status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'LOADING')
|
||||
OR (column_name = 'started_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'started_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'started_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%')
|
||||
OR (column_name = 'completed_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'YES')
|
||||
OR (column_name = 'completed_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'completed_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'YES')
|
||||
OR (column_name = 'version' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0'));
|
||||
IF NOT (
|
||||
(source_total = 11 AND source_smart = 11
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0 AND seq_in_index = 1 AND column_name = 'loading_plan_id') = 1
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'idx_loading_pick_container' AND ((seq_in_index = 1 AND column_name = 'container_no') OR (seq_in_index = 2 AND column_name = 'task_status') OR (seq_in_index = 3 AND column_name = 'started_at'))) = 3
|
||||
AND (SELECT COUNT(*) FROM information_schema.key_column_usage WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND column_name = 'loading_plan_id' AND referenced_table_name = 'fl_loading_plan' AND referenced_column_name = 'id') = 1)
|
||||
OR
|
||||
(source_total = 12 AND source_inbound = 12
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'uk_loading_pick_tenant_inbound' AND non_unique = 0 AND ((seq_in_index = 1 AND column_name = 'tenant_id') OR (seq_in_index = 2 AND column_name = 'inbound_id'))) = 2
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'idx_loading_pick_status' AND ((seq_in_index = 1 AND column_name = 'tenant_id') OR (seq_in_index = 2 AND column_name = 'status') OR (seq_in_index = 3 AND column_name = 'updated_at'))) = 3
|
||||
AND (SELECT COUNT(*) FROM information_schema.key_column_usage WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND column_name = 'inbound_id' AND referenced_table_name = 'fl_inbound_order' AND referenced_column_name = 'id') = 1)
|
||||
OR
|
||||
(source_total = 13 AND source_stable_smart = 13
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0 AND seq_in_index = 1 AND column_name = 'loading_plan_id') = 1
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'uk_loading_pick_task_booking' AND non_unique = 0 AND seq_in_index = 1 AND column_name = 'container_booking_id') = 1
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'idx_loading_pick_container' AND ((seq_in_index = 1 AND column_name = 'container_no') OR (seq_in_index = 2 AND column_name = 'task_status') OR (seq_in_index = 3 AND column_name = 'started_at'))) = 3
|
||||
AND (SELECT COUNT(*) FROM information_schema.key_column_usage WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND column_name = 'loading_plan_id' AND referenced_table_name = 'fl_loading_plan' AND referenced_column_name = 'id') = 1)
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拣货任务迁移失败:fl_loading_pick_task 不是完整 canonical 表型';
|
||||
END IF;
|
||||
END IF;
|
||||
IF target_exists = 1 THEN
|
||||
SELECT COUNT(*) INTO target_total FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task';
|
||||
SELECT COUNT(*) INTO target_inbound FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task' AND (
|
||||
(column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'tenant_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'inbound_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'PENDING')
|
||||
OR (column_name = 'version' AND LOWER(column_type) = 'int' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0')
|
||||
OR (column_name = 'expected_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES')
|
||||
OR (column_name = 'picked_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES')
|
||||
OR (column_name = 'remark' AND LOWER(column_type) = 'varchar(1000)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'updated_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'updated_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'created_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%')
|
||||
OR (column_name = 'updated_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%' AND LOWER(extra) LIKE '%on update current_timestamp%'));
|
||||
IF target_total <> 12 OR target_inbound <> 12
|
||||
OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task' AND index_name = 'uk_loading_pick_tenant_inbound' AND non_unique = 0 AND ((seq_in_index = 1 AND column_name = 'tenant_id') OR (seq_in_index = 2 AND column_name = 'inbound_id'))) <> 2
|
||||
OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task' AND index_name = 'idx_loading_pick_status' AND ((seq_in_index = 1 AND column_name = 'tenant_id') OR (seq_in_index = 2 AND column_name = 'status') OR (seq_in_index = 3 AND column_name = 'updated_at'))) <> 3
|
||||
OR (SELECT COUNT(*) FROM information_schema.key_column_usage WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task' AND column_name = 'inbound_id' AND referenced_table_name = 'fl_inbound_order' AND referenced_column_name = 'id') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拣货任务迁移失败:fl_inbound_loading_pick_task 不是完整 canonical 表型';
|
||||
END IF;
|
||||
END IF;
|
||||
IF require_both = 1 AND (source_exists <> 1 OR target_exists <> 1 OR source_stable_smart <> 13) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拣货任务终验失败:两张 canonical 表未同时就绪';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_loading_pick_task_shapes(0);
|
||||
|
||||
DROP PROCEDURE IF EXISTS migrate_legacy_loading_pick_task;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE migrate_legacy_loading_pick_task()
|
||||
BEGIN
|
||||
DECLARE source_exists INT DEFAULT 0;
|
||||
DECLARE source_smart_columns INT DEFAULT 0;
|
||||
DECLARE source_inbound_columns INT DEFAULT 0;
|
||||
DECLARE target_exists INT DEFAULT 0;
|
||||
DECLARE target_inbound_columns INT DEFAULT 0;
|
||||
|
||||
SELECT COUNT(*) INTO source_exists
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task';
|
||||
IF source_exists = 1 THEN
|
||||
SELECT COUNT(*) INTO source_smart_columns
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
|
||||
AND column_name IN ('loading_plan_id', 'container_no', 'task_status');
|
||||
OR (column_name = 'version' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0')
|
||||
);
|
||||
SELECT COUNT(*) INTO source_inbound_columns
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
|
||||
AND column_name IN ('tenant_id', 'inbound_id', 'status', 'expected_count', 'picked_count');
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND (
|
||||
(column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'tenant_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'inbound_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'PENDING')
|
||||
OR (column_name = 'version' AND LOWER(column_type) = 'int' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0')
|
||||
OR (column_name = 'expected_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES')
|
||||
OR (column_name = 'picked_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES')
|
||||
OR (column_name = 'remark' AND LOWER(column_type) = 'varchar(1000)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'updated_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'updated_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'created_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%')
|
||||
OR (column_name = 'updated_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%' AND LOWER(extra) LIKE '%on update current_timestamp%')
|
||||
);
|
||||
|
||||
IF source_smart_columns = 3 AND source_inbound_columns = 0 THEN
|
||||
IF source_total_columns = 11 AND source_smart_columns = 11 AND source_inbound_columns < 12
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
|
||||
AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0
|
||||
AND seq_in_index = 1 AND column_name = 'loading_plan_id') = 1
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
|
||||
AND index_name = 'idx_loading_pick_container'
|
||||
AND ((seq_in_index = 1 AND column_name = 'container_no')
|
||||
OR (seq_in_index = 2 AND column_name = 'task_status')
|
||||
OR (seq_in_index = 3 AND column_name = 'started_at'))) = 3
|
||||
AND (SELECT COUNT(*) FROM information_schema.key_column_usage
|
||||
WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
|
||||
AND column_name = 'loading_plan_id' AND referenced_table_name = 'fl_loading_plan'
|
||||
AND referenced_column_name = 'id') = 1 THEN
|
||||
SET source_exists = 1;
|
||||
ELSEIF source_smart_columns = 0 AND source_inbound_columns = 5 THEN
|
||||
ELSEIF source_total_columns = 12 AND source_inbound_columns = 12 AND source_smart_columns < 11
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
|
||||
AND index_name = 'uk_loading_pick_tenant_inbound' AND non_unique = 0
|
||||
AND ((seq_in_index = 1 AND column_name = 'tenant_id')
|
||||
OR (seq_in_index = 2 AND column_name = 'inbound_id'))) = 2
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
|
||||
AND index_name = 'idx_loading_pick_status'
|
||||
AND ((seq_in_index = 1 AND column_name = 'tenant_id')
|
||||
OR (seq_in_index = 2 AND column_name = 'status')
|
||||
OR (seq_in_index = 3 AND column_name = 'updated_at'))) = 3
|
||||
AND (SELECT COUNT(*) FROM information_schema.key_column_usage
|
||||
WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
|
||||
AND column_name = 'inbound_id' AND referenced_table_name = 'fl_inbound_order'
|
||||
AND referenced_column_name = 'id') = 1 THEN
|
||||
SELECT COUNT(*) INTO target_exists
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
@@ -5544,14 +5567,44 @@ BEGIN
|
||||
IF target_exists = 0 THEN
|
||||
RENAME TABLE fl_loading_pick_task TO fl_inbound_loading_pick_task;
|
||||
ELSE
|
||||
SELECT COUNT(*) INTO target_total_columns
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task';
|
||||
SELECT COUNT(*) INTO target_inbound_columns
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name = 'fl_inbound_loading_pick_task'
|
||||
AND column_name IN ('tenant_id', 'inbound_id', 'status', 'expected_count', 'picked_count');
|
||||
IF target_inbound_columns <> 5 THEN
|
||||
AND table_name = 'fl_inbound_loading_pick_task' AND (
|
||||
(column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'tenant_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'inbound_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'PENDING')
|
||||
OR (column_name = 'version' AND LOWER(column_type) = 'int' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0')
|
||||
OR (column_name = 'expected_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES')
|
||||
OR (column_name = 'picked_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES')
|
||||
OR (column_name = 'remark' AND LOWER(column_type) = 'varchar(1000)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'updated_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'updated_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'created_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%')
|
||||
OR (column_name = 'updated_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%' AND LOWER(extra) LIKE '%on update current_timestamp%')
|
||||
);
|
||||
IF target_total_columns <> 12 OR target_inbound_columns <> 12
|
||||
OR (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task'
|
||||
AND index_name = 'uk_loading_pick_tenant_inbound' AND non_unique = 0
|
||||
AND ((seq_in_index = 1 AND column_name = 'tenant_id')
|
||||
OR (seq_in_index = 2 AND column_name = 'inbound_id'))) <> 2
|
||||
OR (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task'
|
||||
AND index_name = 'idx_loading_pick_status'
|
||||
AND ((seq_in_index = 1 AND column_name = 'tenant_id')
|
||||
OR (seq_in_index = 2 AND column_name = 'status')
|
||||
OR (seq_in_index = 3 AND column_name = 'updated_at'))) <> 3
|
||||
OR (SELECT COUNT(*) FROM information_schema.key_column_usage
|
||||
WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task'
|
||||
AND column_name = 'inbound_id' AND referenced_table_name = 'fl_inbound_order'
|
||||
AND referenced_column_name = 'id') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '拣货任务迁移失败:fl_inbound_loading_pick_task 不是入库汇总结构';
|
||||
SET MESSAGE_TEXT = '拣货任务迁移失败:fl_inbound_loading_pick_task 不是完整入库汇总结构';
|
||||
END IF;
|
||||
INSERT INTO fl_inbound_loading_pick_task
|
||||
(id, tenant_id, inbound_id, status, version, expected_count, picked_count,
|
||||
@@ -5617,6 +5670,49 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
|
||||
CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED','COMPLETED'))
|
||||
) ENGINE=InnoDB COMMENT='PDA装柜拣货任务';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_completion_command (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '可靠装柜完成命令ID',
|
||||
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA任务ID',
|
||||
operation_id VARCHAR(128) NOT NULL COMMENT '客户端全局幂等操作ID',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '规范化请求摘要',
|
||||
command_status VARCHAR(16) NOT NULL COMMENT '命令状态:PROCESSING处理中、RETRYABLE可重试、FAILED失败、COMPLETED已完成',
|
||||
expected_task_version INT UNSIGNED NOT NULL COMMENT '受理时客户端任务版本',
|
||||
remark VARCHAR(500) NULL COMMENT '完成备注',
|
||||
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
|
||||
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
|
||||
attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'worker认领次数',
|
||||
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次处理时间',
|
||||
lease_owner VARCHAR(128) NULL COMMENT 'worker租约持有者',
|
||||
lease_until DATETIME(3) NULL COMMENT 'worker租约截止时间',
|
||||
failure_stage VARCHAR(40) NULL COMMENT '脱敏失败阶段',
|
||||
failure_code VARCHAR(32) NULL COMMENT '下游或业务错误码',
|
||||
failure_request_id VARCHAR(64) NULL COMMENT '下游请求ID',
|
||||
failure_trace_id VARCHAR(64) 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 '命令更新时间',
|
||||
completed_at DATETIME(3) NULL COMMENT '命令完成时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_loading_completion_operation (operation_id),
|
||||
KEY idx_loading_completion_dispatch (command_status,next_retry_at,lease_until),
|
||||
CONSTRAINT fk_loading_completion_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task(id),
|
||||
CONSTRAINT chk_loading_completion_status CHECK (command_status IN ('PROCESSING','RETRYABLE','FAILED','COMPLETED'))
|
||||
) ENGINE=InnoDB COMMENT='PDA装柜完成可靠异步命令';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_completion_checkpoint (
|
||||
command_id BIGINT UNSIGNED NOT NULL COMMENT '可靠装柜完成命令ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '入仓单客户ID',
|
||||
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入仓单ID',
|
||||
owner_sequence INT UNSIGNED NOT NULL COMMENT '稳定处理顺序',
|
||||
checkpoint_stage VARCHAR(24) NOT NULL COMMENT '处理阶段:READY待处理、SETTLED资源已结算、INBOUND_COMPLETED入仓已完成',
|
||||
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 (command_id,tenant_id,inbound_id),
|
||||
KEY idx_loading_completion_owner (command_id,owner_sequence),
|
||||
CONSTRAINT fk_loading_completion_checkpoint_command FOREIGN KEY (command_id)
|
||||
REFERENCES fl_loading_completion_command(id),
|
||||
CONSTRAINT chk_loading_completion_checkpoint CHECK (checkpoint_stage IN ('READY','SETTLED','INBOUND_COMPLETED'))
|
||||
) ENGINE=InnoDB COMMENT='PDA装柜完成按入库单恢复检查点';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
|
||||
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
|
||||
@@ -5626,6 +5722,8 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
|
||||
planned_package_count INT UNSIGNED NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整',
|
||||
picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已拣货箱数',
|
||||
item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '拣货明细状态:PENDING待拣、PICKING拣货中、PICKED已拣完、PARTIAL部分拣货、SKIPPED跳拣',
|
||||
release_mode VARCHAR(10) NULL COMMENT 'AUTO按比例释放,MANUAL按人工选择释放',
|
||||
release_location_ids JSON NULL COMMENT '人工选择释放库位ID;空数组表示明确释放0个',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货明细创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货明细最后更新时间',
|
||||
@@ -5658,8 +5756,6 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history (
|
||||
CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id)
|
||||
) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史';
|
||||
|
||||
|
||||
|
||||
DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE repair_phase3_loading_pick_task_foreign_keys()
|
||||
@@ -6031,8 +6127,10 @@ BEGIN
|
||||
OR NOT (NEW.cabinet_no <=> OLD.cabinet_no) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务稳定归属不可修改';
|
||||
END IF;
|
||||
IF NOT (OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING', 'COMPLETED')) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态只允许完成';
|
||||
IF NOT ((OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING','COMPLETING'))
|
||||
OR (OLD.task_status = 'COMPLETING' AND NEW.task_status IN ('COMPLETING','COMPLETION_FAILED','COMPLETED'))
|
||||
OR (OLD.task_status = 'COMPLETION_FAILED' AND NEW.task_status = 'COMPLETING')) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态流转非法';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
@@ -6194,10 +6292,11 @@ DELIMITER ;
|
||||
|
||||
-- 三期智能装柜、预装清单和装柜清单平台权限;与一期平台根菜单幂等合并。
|
||||
USE baiyue_iam;
|
||||
DROP PROCEDURE IF EXISTS assert_smart_loading_permission_ids_available;
|
||||
DROP PROCEDURE IF EXISTS relocate_legacy_inbound_loading_permissions;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_smart_loading_permission_ids_available()
|
||||
CREATE PROCEDURE relocate_legacy_inbound_loading_permissions()
|
||||
BEGIN
|
||||
DECLARE legacy_count INT DEFAULT 0;
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM iam_permission
|
||||
WHERE id IN (5102091, 5102092, 5102093, 5102094, 5102096, 5102097, 5102098, 5102099)
|
||||
@@ -6215,10 +6314,89 @@ BEGIN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '三期权限迁移失败:5102091-5102099 存在未知权限占用';
|
||||
END IF;
|
||||
SELECT COUNT(*) INTO legacy_count
|
||||
FROM iam_permission
|
||||
WHERE tenant_id = 0 AND app_code = 'PLATFORM'
|
||||
AND permission_code IN (
|
||||
'firstleg:inbound:split', 'firstleg:inbound:preload', 'firstleg:inbound:preload-view',
|
||||
'platform:firstleg:inbound:loading:edit', 'platform:firstleg:inbound:loading:view',
|
||||
'platform:firstleg:inbound:loading:complete', 'platform:firstleg:inbound:delivery:complete',
|
||||
'platform:firstleg:inbound:picking:edit', 'platform:firstleg:inbound:picking:view'
|
||||
);
|
||||
IF legacy_count > 0 THEN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM iam_permission
|
||||
WHERE id BETWEEN 5102121 AND 5102129
|
||||
AND permission_code <> CASE id
|
||||
WHEN 5102121 THEN 'firstleg:inbound:split'
|
||||
WHEN 5102122 THEN 'firstleg:inbound:preload'
|
||||
WHEN 5102123 THEN 'firstleg:inbound:preload-view'
|
||||
WHEN 5102124 THEN 'platform:firstleg:inbound:loading:edit'
|
||||
WHEN 5102125 THEN 'platform:firstleg:inbound:loading:view'
|
||||
WHEN 5102126 THEN 'platform:firstleg:inbound:loading:complete'
|
||||
WHEN 5102127 THEN 'platform:firstleg:inbound:delivery:complete'
|
||||
WHEN 5102128 THEN 'platform:firstleg:inbound:picking:edit'
|
||||
WHEN 5102129 THEN 'platform:firstleg:inbound:picking:view'
|
||||
END
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '三期权限迁移失败:5102121-5102129 已被其他权限占用';
|
||||
END IF;
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_phase3_inbound_role_grant;
|
||||
CREATE TEMPORARY TABLE tmp_phase3_inbound_role_grant (
|
||||
role_id BIGINT UNSIGNED NOT NULL,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
permission_code VARCHAR(128) NOT NULL,
|
||||
PRIMARY KEY (role_id, tenant_id, permission_code)
|
||||
) ENGINE=MEMORY;
|
||||
INSERT IGNORE INTO tmp_phase3_inbound_role_grant (role_id, tenant_id, permission_code)
|
||||
SELECT role_permission.role_id, role_permission.tenant_id, permission.permission_code
|
||||
FROM iam_role_permission role_permission
|
||||
JOIN iam_permission permission ON permission.id = role_permission.permission_id
|
||||
WHERE permission.permission_code IN (
|
||||
'firstleg:inbound:split', 'firstleg:inbound:preload', 'firstleg:inbound:preload-view',
|
||||
'platform:firstleg:inbound:loading:edit', 'platform:firstleg:inbound:loading:view',
|
||||
'platform:firstleg:inbound:loading:complete', 'platform:firstleg:inbound:delivery:complete',
|
||||
'platform:firstleg:inbound:picking:edit', 'platform:firstleg:inbound:picking:view'
|
||||
);
|
||||
DELETE role_permission FROM iam_role_permission role_permission
|
||||
JOIN iam_permission permission ON permission.id = role_permission.permission_id
|
||||
WHERE permission.permission_code IN (
|
||||
'firstleg:inbound:split', 'firstleg:inbound:preload', 'firstleg:inbound:preload-view',
|
||||
'platform:firstleg:inbound:loading:edit', 'platform:firstleg:inbound:loading:view',
|
||||
'platform:firstleg:inbound:loading:complete', 'platform:firstleg:inbound:delivery:complete',
|
||||
'platform:firstleg:inbound:picking:edit', 'platform:firstleg:inbound:picking:view'
|
||||
);
|
||||
DELETE FROM iam_permission WHERE permission_code IN (
|
||||
'firstleg:inbound:split', 'firstleg:inbound:preload', 'firstleg:inbound:preload-view',
|
||||
'platform:firstleg:inbound:loading:edit', 'platform:firstleg:inbound:loading:view',
|
||||
'platform:firstleg:inbound:loading:complete', 'platform:firstleg:inbound:delivery:complete',
|
||||
'platform:firstleg:inbound:picking:edit', 'platform:firstleg:inbound:picking:view'
|
||||
);
|
||||
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
|
||||
(5102121, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:split', '智能分柜', NULL, NULL, 140, 'ACTIVE', 1, 0),
|
||||
(5102122, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:preload', '编辑并确认预装清单', NULL, NULL, 150, 'ACTIVE', 1, 0),
|
||||
(5102123, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:preload-view', '查看预装清单', NULL, NULL, 160, 'ACTIVE', 1, 0),
|
||||
(5102124, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:loading:edit', '维护装柜证据', NULL, NULL, 170, 'ACTIVE', 1, 0),
|
||||
(5102125, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:loading:view', '查看装柜证据', NULL, NULL, 180, 'ACTIVE', 1, 0),
|
||||
(5102126, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:loading:complete', '完成装柜', NULL, NULL, 190, 'ACTIVE', 1, 0),
|
||||
(5102127, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:delivery:complete', '完成派送', NULL, NULL, 200, 'ACTIVE', 1, 0),
|
||||
(5102128, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:picking:edit', '执行拣货', NULL, NULL, 210, 'ACTIVE', 1, 0),
|
||||
(5102129, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:picking:view', '查看拣货任务', NULL, NULL, 220, 'ACTIVE', 1, 0);
|
||||
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT grant_row.role_id, permission.id, grant_row.tenant_id
|
||||
FROM tmp_phase3_inbound_role_grant grant_row
|
||||
JOIN iam_permission permission ON permission.permission_code = grant_row.permission_code
|
||||
AND permission.tenant_id = 0 AND permission.app_code = 'PLATFORM';
|
||||
DROP TEMPORARY TABLE tmp_phase3_inbound_role_grant;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_smart_loading_permission_ids_available();
|
||||
DROP PROCEDURE assert_smart_loading_permission_ids_available;
|
||||
CALL relocate_legacy_inbound_loading_permissions();
|
||||
DROP PROCEDURE relocate_legacy_inbound_loading_permissions;
|
||||
|
||||
DELETE role_permission FROM iam_role_permission role_permission
|
||||
JOIN iam_permission permission ON permission.id = role_permission.permission_id
|
||||
@@ -6274,7 +6452,7 @@ FROM iam_role role
|
||||
'platform:firstleg:container:list', 'platform:firstleg:container:view',
|
||||
'platform:firstleg:container:create',
|
||||
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
|
||||
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
|
||||
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
|
||||
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
|
||||
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
|
||||
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
|
||||
@@ -6285,6 +6463,12 @@ WHERE role.tenant_id = 0
|
||||
USE baiyue_firstleg;
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema;
|
||||
|
||||
DELETE rp FROM baiyue_iam.iam_role_permission rp
|
||||
JOIN baiyue_iam.iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id
|
||||
WHERE p.permission_code IN ('platform:firstleg:container:view','platform:firstleg:preload:view','platform:firstleg:loading-list:view');
|
||||
UPDATE baiyue_iam.iam_permission SET status='INACTIVE',deleted=1
|
||||
WHERE permission_code IN ('platform:firstleg:container:view','platform:firstleg:preload:view','platform:firstleg:loading-list:view');
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_phase3_smart_loading_schema()
|
||||
BEGIN
|
||||
@@ -6426,9 +6610,118 @@ BEGIN
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
-- ============================================================
|
||||
DROP PROCEDURE IF EXISTS assert_loading_pick_task_shapes;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_loading_pick_task_shapes(IN require_both TINYINT)
|
||||
BEGIN
|
||||
DECLARE source_exists INT DEFAULT 0;
|
||||
DECLARE target_exists INT DEFAULT 0;
|
||||
DECLARE source_total INT DEFAULT 0;
|
||||
DECLARE source_smart INT DEFAULT 0;
|
||||
DECLARE source_stable_smart INT DEFAULT 0;
|
||||
DECLARE source_inbound INT DEFAULT 0;
|
||||
DECLARE target_total INT DEFAULT 0;
|
||||
DECLARE target_inbound INT DEFAULT 0;
|
||||
SELECT COUNT(*) INTO source_exists FROM information_schema.tables
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task';
|
||||
SELECT COUNT(*) INTO target_exists FROM information_schema.tables
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task';
|
||||
IF source_exists = 1 THEN
|
||||
SELECT COUNT(*) INTO source_total FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task';
|
||||
SELECT COUNT(*) INTO source_smart FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND (
|
||||
(column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'loading_plan_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'container_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'NO')
|
||||
OR (column_name = 'task_status' AND LOWER(column_type) = 'varchar(16)' AND is_nullable = 'NO' AND column_default = 'LOADING')
|
||||
OR (column_name = 'started_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'started_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'started_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%')
|
||||
OR (column_name = 'completed_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'YES')
|
||||
OR (column_name = 'completed_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'completed_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'YES')
|
||||
OR (column_name = 'version' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0'));
|
||||
SELECT COUNT(*) INTO source_inbound FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND (
|
||||
(column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'tenant_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'inbound_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'PENDING')
|
||||
OR (column_name = 'version' AND LOWER(column_type) = 'int' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0')
|
||||
OR (column_name = 'expected_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES')
|
||||
OR (column_name = 'picked_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES')
|
||||
OR (column_name = 'remark' AND LOWER(column_type) = 'varchar(1000)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'updated_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'updated_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'created_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%')
|
||||
OR (column_name = 'updated_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%' AND LOWER(extra) LIKE '%on update current_timestamp%'));
|
||||
SELECT COUNT(*) INTO source_stable_smart FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND (
|
||||
(column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'loading_plan_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'container_booking_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'cabinet_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'NO')
|
||||
OR (column_name = 'container_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'task_status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'LOADING')
|
||||
OR (column_name = 'started_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'started_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'started_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%')
|
||||
OR (column_name = 'completed_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'YES')
|
||||
OR (column_name = 'completed_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'completed_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'YES')
|
||||
OR (column_name = 'version' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0'));
|
||||
IF NOT (
|
||||
(source_total = 11 AND source_smart = 11
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0 AND seq_in_index = 1 AND column_name = 'loading_plan_id') = 1
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'idx_loading_pick_container' AND ((seq_in_index = 1 AND column_name = 'container_no') OR (seq_in_index = 2 AND column_name = 'task_status') OR (seq_in_index = 3 AND column_name = 'started_at'))) = 3
|
||||
AND (SELECT COUNT(*) FROM information_schema.key_column_usage WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND column_name = 'loading_plan_id' AND referenced_table_name = 'fl_loading_plan' AND referenced_column_name = 'id') = 1)
|
||||
OR
|
||||
(source_total = 12 AND source_inbound = 12
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'uk_loading_pick_tenant_inbound' AND non_unique = 0 AND ((seq_in_index = 1 AND column_name = 'tenant_id') OR (seq_in_index = 2 AND column_name = 'inbound_id'))) = 2
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'idx_loading_pick_status' AND ((seq_in_index = 1 AND column_name = 'tenant_id') OR (seq_in_index = 2 AND column_name = 'status') OR (seq_in_index = 3 AND column_name = 'updated_at'))) = 3
|
||||
AND (SELECT COUNT(*) FROM information_schema.key_column_usage WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND column_name = 'inbound_id' AND referenced_table_name = 'fl_inbound_order' AND referenced_column_name = 'id') = 1)
|
||||
OR
|
||||
(source_total = 13 AND source_stable_smart = 13
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0 AND seq_in_index = 1 AND column_name = 'loading_plan_id') = 1
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'uk_loading_pick_task_booking' AND non_unique = 0 AND seq_in_index = 1 AND column_name = 'container_booking_id') = 1
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'idx_loading_pick_container' AND ((seq_in_index = 1 AND column_name = 'container_no') OR (seq_in_index = 2 AND column_name = 'task_status') OR (seq_in_index = 3 AND column_name = 'started_at'))) = 3
|
||||
AND (SELECT COUNT(*) FROM information_schema.key_column_usage WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND column_name = 'loading_plan_id' AND referenced_table_name = 'fl_loading_plan' AND referenced_column_name = 'id') = 1)
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拣货任务迁移失败:fl_loading_pick_task 不是完整 canonical 表型';
|
||||
END IF;
|
||||
END IF;
|
||||
IF target_exists = 1 THEN
|
||||
SELECT COUNT(*) INTO target_total FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task';
|
||||
SELECT COUNT(*) INTO target_inbound FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task' AND (
|
||||
(column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'tenant_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'inbound_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'PENDING')
|
||||
OR (column_name = 'version' AND LOWER(column_type) = 'int' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0')
|
||||
OR (column_name = 'expected_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES')
|
||||
OR (column_name = 'picked_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES')
|
||||
OR (column_name = 'remark' AND LOWER(column_type) = 'varchar(1000)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'updated_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'updated_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'created_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%')
|
||||
OR (column_name = 'updated_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%' AND LOWER(extra) LIKE '%on update current_timestamp%'));
|
||||
IF target_total <> 12 OR target_inbound <> 12
|
||||
OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task' AND index_name = 'uk_loading_pick_tenant_inbound' AND non_unique = 0 AND ((seq_in_index = 1 AND column_name = 'tenant_id') OR (seq_in_index = 2 AND column_name = 'inbound_id'))) <> 2
|
||||
OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task' AND index_name = 'idx_loading_pick_status' AND ((seq_in_index = 1 AND column_name = 'tenant_id') OR (seq_in_index = 2 AND column_name = 'status') OR (seq_in_index = 3 AND column_name = 'updated_at'))) <> 3
|
||||
OR (SELECT COUNT(*) FROM information_schema.key_column_usage WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task' AND column_name = 'inbound_id' AND referenced_table_name = 'fl_inbound_order' AND referenced_column_name = 'id') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拣货任务迁移失败:fl_inbound_loading_pick_task 不是完整 canonical 表型';
|
||||
END IF;
|
||||
END IF;
|
||||
IF require_both = 1 AND (source_exists <> 1 OR target_exists <> 1 OR source_stable_smart <> 13) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拣货任务终验失败:两张 canonical 表未同时就绪';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_loading_pick_task_shapes(0);
|
||||
|
||||
-- 2026-08-06 平台公共仓库正式主数据:固定作业仓 + FBA 地址库
|
||||
-- 来源:warehouse/src/main/resources/db/warehouse-public-master-data.sql。必须位于最终 PASS 校验之前,可重复执行。
|
||||
USE baiyue_warehouse;
|
||||
|
||||
@@ -10833,7 +11126,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
|
||||
CONSTRAINT fk_carrier_schedule_query_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
|
||||
CONSTRAINT fk_carrier_schedule_query_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id),
|
||||
CONSTRAINT fk_carrier_schedule_query_result FOREIGN KEY (result_port_schedule_id) REFERENCES fl_port_schedule(id),
|
||||
CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id > 0),
|
||||
CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id >= 0),
|
||||
CONSTRAINT chk_carrier_schedule_query_carrier CHECK
|
||||
(carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')),
|
||||
CONSTRAINT chk_carrier_schedule_query_tracking CHECK
|
||||
@@ -10895,7 +11188,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '标准船节点事实ID',
|
||||
query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID',
|
||||
query_id BIGINT UNSIGNED NULL COMMENT '自动跟踪任务ID;人工补录时为空',
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属真实货柜ID',
|
||||
event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键',
|
||||
provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码',
|
||||
provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID',
|
||||
@@ -10911,9 +11205,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
|
||||
raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_carrier_schedule_node_event (query_id,event_key),
|
||||
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
|
||||
KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id),
|
||||
KEY idx_container_shipping_node_timeline (container_booking_id,observed_at,id),
|
||||
CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
|
||||
CONSTRAINT fk_carrier_schedule_node_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id),
|
||||
CONSTRAINT chk_carrier_schedule_node_standard CHECK
|
||||
(standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT',
|
||||
'IN_TRANSIT','ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')),
|
||||
@@ -11109,6 +11405,34 @@ END$$
|
||||
DELIMITER ;
|
||||
CALL assert_carrier_schedule_query_schema();
|
||||
DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_schema;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_carrier_schedule_query_schema()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema='baiyue_firstleg' AND table_name IN
|
||||
('fl_carrier_schedule_query','fl_carrier_schedule_query_attempt',
|
||||
'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation',
|
||||
'fl_carrier_schedule_node_fact','fl_carrier_schedule_exception_fact')) <> 6 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务表未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
|
||||
WHERE tenant_id=0 AND id IN (5102184,5102186,5102188)
|
||||
AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 3 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务权限未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*)
|
||||
FROM baiyue_iam.iam_role_permission role_permission
|
||||
JOIN baiyue_iam.iam_role role ON role.id=role_permission.role_id
|
||||
JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id
|
||||
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
|
||||
AND permission.tenant_id=0
|
||||
AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 6 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务角色授权未完整创建';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_carrier_schedule_query_schema();
|
||||
DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_schema;
|
||||
|
||||
DROP PROCEDURE IF EXISTS migrate_carrier_schedule_retry_columns;
|
||||
DELIMITER $$
|
||||
@@ -13564,6 +13888,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'deleted
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'effective_from', '生效开始时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'effective_to', '生效结束时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'id', '客户计费规则ID');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'initialization_key', '客户创建执行器幂等键及补偿归属');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'remark', '规则备注');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'rounding_mode', '舍入方式');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'rounding_scale', '结果保留小数位数');
|
||||
@@ -15754,6 +16079,16 @@ BEGIN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:管理员或订单运营角色缺少订单导出与下载中心权限';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
|
||||
WHERE id = 5102027 AND tenant_id = 0 AND app_code = 'PLATFORM'
|
||||
AND permission_code = 'platform:order:declaration:edit'
|
||||
AND parent_id = 5102010 AND status = 'ACTIVE' AND deleted = 0) <> 1
|
||||
OR (SELECT COUNT(DISTINCT role_id) FROM baiyue_iam.iam_role_permission
|
||||
WHERE tenant_id = 0 AND permission_id = 5102027
|
||||
AND role_id IN (4000001, 4000003)) <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:订单申报与运营预估权限未正确授予平台管理员和订单运营角色';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
|
||||
WHERE tenant_id = 0 AND app_code = 'PLATFORM'
|
||||
AND id BETWEEN 5102121 AND 5102129
|
||||
@@ -18867,6 +19202,17 @@ DROP PROCEDURE IF EXISTS add_finance_p0_column;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_finance' AND table_name='fin_fee_source' AND index_name='idx_fin_fee_source_wallet_hold'), 'SELECT 1', 'ALTER TABLE baiyue_finance.fin_fee_source ADD KEY idx_fin_fee_source_wallet_hold(wallet_hold_id)'));
|
||||
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
|
||||
|
||||
-- 跨客户合柜的订舱属于平台技术作用域,船节点任务使用 customer_id=0。
|
||||
USE baiyue_firstleg;
|
||||
SET @ddl = (SELECT IF(EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query'
|
||||
AND constraint_name='chk_carrier_schedule_query_customer' AND constraint_type='CHECK'
|
||||
), 'ALTER TABLE fl_carrier_schedule_query DROP CHECK chk_carrier_schedule_query_customer', 'SELECT 1'));
|
||||
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
|
||||
ALTER TABLE fl_carrier_schedule_query
|
||||
ADD CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id >= 0);
|
||||
|
||||
CALL baiyue_firstleg.assert_unified_final_schema();
|
||||
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
|
||||
SELECT 'PASS' AS migration_status,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
-- 通用 IAM 权限点独立初始化入口(MySQL 8.0)。
|
||||
-- 国内与海外业务的 Admin/Client 权限主数据均在 baiyue_iam 统一管理。
|
||||
-- 只允许写入 baiyue_iam 的权限资源和角色授权;
|
||||
-- 不建表、不创建账号/客户/仓库,不写业务测试数据。
|
||||
-- 前置:IAM 基础表、相关父菜单及系统角色已由全量基线创建。
|
||||
USE baiyue_iam;
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
DROP PROCEDURE IF EXISTS apply_permission_points;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE apply_permission_points()
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM iam_permission WHERE id = 5102010 AND tenant_id = 0
|
||||
AND permission_code = 'platform:order:list' AND permission_type = 'MENU') THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '权限初始化失败:订单列表父菜单不存在,请先执行全量基线';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM iam_role WHERE id IN (4000001, 4000003) AND tenant_id = 0
|
||||
AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0) <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '权限初始化失败:平台管理员或订单运营角色不存在';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM iam_permission WHERE id = 5102027
|
||||
AND permission_code <> 'platform:order:declaration:edit')
|
||||
OR EXISTS (SELECT 1 FROM iam_permission
|
||||
WHERE permission_code = 'platform:order:declaration:edit' AND id <> 5102027) THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '权限初始化失败:5102027 或权限码已被其他语义占用';
|
||||
END IF;
|
||||
|
||||
START TRANSACTION;
|
||||
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
|
||||
(5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit',
|
||||
'修改订单申报与运营预估', NULL, NULL, 130, 'ACTIVE', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id),
|
||||
permission_type = VALUES(permission_type), app_code = VALUES(app_code),
|
||||
sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0;
|
||||
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT role_id, 5102027, 0
|
||||
FROM (SELECT 4000001 AS role_id UNION ALL SELECT 4000003 AS role_id) roles
|
||||
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
|
||||
|
||||
IF (SELECT COUNT(*) FROM iam_permission WHERE id = 5102027 AND tenant_id = 0
|
||||
AND app_code = 'PLATFORM' AND permission_type = 'BUTTON' AND parent_id = 5102010
|
||||
AND permission_code = 'platform:order:declaration:edit'
|
||||
AND status = 'ACTIVE' AND deleted = 0) <> 1
|
||||
OR (SELECT COUNT(DISTINCT role_id) FROM iam_role_permission
|
||||
WHERE tenant_id = 0 AND permission_id = 5102027
|
||||
AND role_id IN (4000001, 4000003)) <> 2 THEN
|
||||
ROLLBACK;
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '权限初始化失败:订单申报与运营预估权限未完整写入';
|
||||
END IF;
|
||||
COMMIT;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
CALL apply_permission_points();
|
||||
DROP PROCEDURE IF EXISTS apply_permission_points;
|
||||
|
||||
SELECT 'PASS' AS permission_initialization_status;
|
||||
@@ -1,6 +1,7 @@
|
||||
-- 海外区域唯一数据库初始化入口;空库和存量测试库均只执行本文件。
|
||||
-- 本文件内联 Lastleg 与海外仓空库基线,再执行统一增量和最终结构校验。
|
||||
-- 本文件只由人工审核执行,服务部署默认不自动执行 SQL。
|
||||
-- 空库先执行 lastleg-service/src/main/resources/db/lastleg.sql 基线;本入口同时内联该基线用于固定部署。
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- 海外 Event Bridge 空库基线必须先于业务服务表初始化。
|
||||
@@ -112,7 +113,7 @@ CREATE TABLE IF NOT EXISTS ll_loading_event_inbox (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_container_shipping_gate (
|
||||
container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID',
|
||||
ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID', container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号',
|
||||
ocean_booking_id VARCHAR(128) NULL COMMENT '来源海运订舱ID;货柜未关联订舱时为空', container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE',
|
||||
standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点', node_sequence INT NOT NULL DEFAULT 0 COMMENT '最新有效节点单调序号',
|
||||
observed_at DATETIME(3) NULL COMMENT '供应商节点观测时间', actual_at DATETIME(3) NULL COMMENT '节点实际发生时间',
|
||||
@@ -3321,7 +3322,7 @@ PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCA
|
||||
-- 海运节点达到可提柜后才允许海外 PDA 认领;装柜完成只建立等待门禁快照。
|
||||
CREATE TABLE IF NOT EXISTS ll_container_shipping_gate (
|
||||
container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID',
|
||||
ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID',
|
||||
ocean_booking_id VARCHAR(128) NULL COMMENT '来源海运订舱ID;货柜未关联订舱时为空',
|
||||
container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE',
|
||||
standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点',
|
||||
@@ -4587,3 +4588,7 @@ CALL assert_overseas_finance_p0_ready();
|
||||
DROP PROCEDURE IF EXISTS assert_overseas_finance_p0_ready;
|
||||
|
||||
SELECT 'PASS' AS migration_status, '海外财务P0费用来源字段已就绪' AS message;
|
||||
|
||||
-- 人工货柜节点独立于订舱:货柜尚未关联海运订舱时仍可同步当前节点。
|
||||
ALTER TABLE ll_container_shipping_gate
|
||||
MODIFY ocean_booking_id VARCHAR(128) NULL COMMENT '来源海运订舱ID;货柜未关联订舱时为空';
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
| 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` |
|
||||
| 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` |
|
||||
| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
|
||||
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;`FINANCE_VOUCHER` 按客户与业务 ID 精确校验;`OVERSEAS_UNLOAD_PHOTO` 是多客户共享柜的物理证据,固定平台范围 `tenantId=0` 并按收货会话 ID 精确校验 |
|
||||
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;`FINANCE_VOUCHER` 按客户与业务 ID 精确校验;`OVERSEAS_UNLOAD_PHOTO` 是允许同柜多客户的柜级物理证据,固定平台范围 `tenantId=0` 并按接柜会话 ID 精确校验 |
|
||||
| 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg |
|
||||
| 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,Lastleg 校验申请/明细关系、客户归属、权限和业务状态 |
|
||||
| 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 |
|
||||
|
||||
@@ -56,7 +56,8 @@ enum BusinessFileCategory {
|
||||
|
||||
void validateOwner(long tenantId) {
|
||||
if ((this == OCEAN_BOOKING_DOCUMENT || this == OVERSEAS_UNLOAD_PHOTO) && tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "该附件类型必须归属平台");
|
||||
throw new BizException(ResultCode.BAD_REQUEST,
|
||||
this == OCEAN_BOOKING_DOCUMENT ? "订舱附件必须归属平台" : "海外卸柜照片必须归属平台");
|
||||
}
|
||||
if (customerOwned && tenantId <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "该附件类型必须归属明确客户");
|
||||
|
||||
@@ -44,6 +44,8 @@ class InternalBusinessFileServiceTest {
|
||||
when(storage.store(eq(0L), anyString(), same(file), any(BusinessFileCategory.class))).thenAnswer(invocation ->
|
||||
new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf",
|
||||
"application/pdf", file.getSize(), "b".repeat(64), "customs.pdf"));
|
||||
when(storage.store(0L, "51", file, BusinessFileCategory.OVERSEAS_UNLOAD_PHOTO)).thenReturn(new BusinessFileStorage.Stored(
|
||||
"business-files/0/51/unload.jpg", "image/jpeg", file.getSize(), "c".repeat(64), "unload.jpg"));
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
|
||||
((FileObjectEntity) invocation.getArgument(0)).setId(81L); return 1;
|
||||
});
|
||||
@@ -72,7 +74,7 @@ class InternalBusinessFileServiceTest {
|
||||
verify(storage).store(0L, "41", file, BusinessFileCategory.CONTAINER_PICKUP_ORDER);
|
||||
verify(storage).store(0L, "51", file, BusinessFileCategory.OVERSEAS_UNLOAD_PHOTO);
|
||||
assertThatThrownBy(() -> service.upload(11L, "OVERSEAS_UNLOAD_PHOTO", "51", 7L, file))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("必须归属平台");
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("卸柜照片必须归属平台");
|
||||
}
|
||||
|
||||
@Test void oceanBookingDocumentUsesPlatformAndBookingOwnership() {
|
||||
|
||||
+2
-2
@@ -86,8 +86,8 @@
|
||||
## 国内货柜履约装柜与 PDA 资源结算
|
||||
|
||||
- 智能装柜全局工作台由服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
|
||||
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间并将该费用版本永久锁定,平台不得再调整;旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
|
||||
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
|
||||
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
|
||||
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。装柜允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责;PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
|
||||
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
|
||||
- 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 尽力预留并确认整票库位块;有效体积先取入库单已落地的正数实测体积,否则对最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值,再无有效值才查询 Order 最新客户申报体积,全部无效则拒绝。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId/sharedOverflow` 不得变化。库位指引失败只记录警告,不阻断称重确认;测量提交后以最新已确认图片调用 `ensureForSubmission` 再次补齐和校验,失败同样不回滚已提交的测量主流程。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。
|
||||
- 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ public class InternalCustomerDimensionConfigController {
|
||||
@Valid @RequestBody InitializeRequest request) {
|
||||
requireToken(token);
|
||||
return ApiResponse.success(service.initializeDefault(
|
||||
request.tenantId(), request.operatorId(), request.initializationKey()));
|
||||
request.tenantId(), request.operatorId(), request.initializationKey(), request.customerName()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/defaults")
|
||||
|
||||
@@ -92,7 +92,6 @@ public class PlatformContainerController {
|
||||
@PathVariable @Positive long id,
|
||||
@PathVariable @Positive long fileId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
service.requireDomestic(id);
|
||||
var value = overview.downloadPhoto(id, fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.inline()
|
||||
|
||||
+34
@@ -93,6 +93,40 @@ public class PlatformInboundMeasurementController {
|
||||
return ApiResponse.success(service.saveDraft(tenantId, inboundId, user.userId(), request));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "INBOUND_MEASUREMENT_CALCULATION_STRATEGY_UPDATE", resourceType = "INBOUND",
|
||||
resourceId = "#inboundId", description = "更新入库测量箱规计算规则")
|
||||
@Operation(summary = "更新箱规计算规则",
|
||||
description = "独立更新当前测量批次全部明细的箱规计算规则并重算体积;不改变测量提交或审核状态。"
|
||||
+ "仅当客户已确认费用且该票已装柜完成时冻结。")
|
||||
@PutMapping("/measurement/calculation-strategy")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')")
|
||||
public ApiResponse<InboundMeasurementResponses.CalculationStrategyUpdated> updateCalculationStrategy(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long inboundId,
|
||||
@Valid @RequestBody InboundMeasurementRequests.UpdateCalculationStrategyRequest request) {
|
||||
requirePlatformTenant(user, tenantId);
|
||||
return ApiResponse.success(service.updateCalculationStrategy(tenantId, inboundId, request));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "INBOUND_MEASUREMENT_OUTER_DIMENSIONS_UPDATE",
|
||||
resourceType = "INBOUND_MEASUREMENT_ITEM", resourceId = "#itemId",
|
||||
description = "更新仓库外箱长宽高")
|
||||
@Operation(summary = "更新仓库外箱长宽高",
|
||||
description = "独立更新当前测量批次指定明细的仓库外箱尺寸并重算体积;不改变测量提交或审核状态。"
|
||||
+ "仅当客户已确认费用且该票已装柜完成时冻结。")
|
||||
@PutMapping("/measurement/items/{itemId}/outer-dimensions")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')")
|
||||
public ApiResponse<InboundMeasurementResponses.OuterDimensionsUpdated> updateOuterDimensions(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long inboundId,
|
||||
@PathVariable long itemId,
|
||||
@Valid @RequestBody InboundMeasurementRequests.UpdateOuterDimensionsRequest request) {
|
||||
requirePlatformTenant(user, tenantId);
|
||||
return ApiResponse.success(service.updateOuterDimensions(tenantId, inboundId, itemId, request));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "INBOUND_MEASUREMENT_SUBMIT", resourceType = "INBOUND",
|
||||
resourceId = "#inboundId", description = "确认测量并提交审核")
|
||||
@Operation(summary = "确认测量并提交审核",
|
||||
|
||||
+15
@@ -10,6 +10,7 @@ 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.dto.InboundCartonLabels;
|
||||
import com.baiyue.firstleg.dto.InboundHoldDtos.CommandRequest;
|
||||
import com.baiyue.firstleg.dto.InboundHoldDtos.Detail;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
@@ -22,6 +23,7 @@ import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
@@ -29,6 +31,7 @@ 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.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -160,6 +163,18 @@ public class PlatformInboundOrderController {
|
||||
return cartonLabelPrintService.generate(tenantId, inboundId, cartonIndex, requestId);
|
||||
}
|
||||
|
||||
@Operation(summary = "批量生成入仓箱唛 PDF",
|
||||
description = "按所选客户和入库单聚合生成一个 PDF;首次调用冻结各入库单整单箱号。")
|
||||
@PostMapping("/carton-label-pdf")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:view')")
|
||||
public ResponseEntity<byte[]> printCartonLabelsBatch(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestBody @Size(min = 1, max = 200) List<InboundCartonLabels.PlatformBatchItem> items,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
|
||||
requirePlatform(user);
|
||||
return cartonLabelPrintService.generateForPlatformInbounds(items, requestId);
|
||||
}
|
||||
|
||||
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
|
||||
FirstlegScopeGuard.platform(user, tenantId);
|
||||
}
|
||||
|
||||
+2
-1
@@ -22,7 +22,8 @@ public final class ContainerCustomsDtos {
|
||||
boolean frozen, FileView completion) {}
|
||||
|
||||
public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId,
|
||||
String planNo, String cabinetNo, String containerNo, String status, String merchantStatus,
|
||||
String planNo, String cabinetNo, String containerNo, String status, boolean hasActualCargo,
|
||||
String merchantStatus,
|
||||
int version, int taxRefundCount, int coveredTaxRefundCount,
|
||||
List<InboundView> inbounds, List<DocumentView> documents,
|
||||
LocalDateTime startedAt, LocalDateTime completedAt) {}
|
||||
|
||||
+11
@@ -34,6 +34,15 @@ public interface ContainerCustomsMapper {
|
||||
""")
|
||||
List<InboundRow> planInbounds(@Param("planId") long planId);
|
||||
|
||||
@Select("""
|
||||
SELECT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId,
|
||||
p.plan_status loadingPlanStatus,b.cabinet_no cabinetNo,b.container_no containerNo
|
||||
FROM fl_loading_plan p
|
||||
JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
|
||||
WHERE p.id=#{planId}
|
||||
""")
|
||||
PlanRow plan(@Param("planId") long planId);
|
||||
|
||||
@Select("""
|
||||
SELECT p.id FROM fl_loading_plan p
|
||||
JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0
|
||||
@@ -331,6 +340,8 @@ public interface ContainerCustomsMapper {
|
||||
record InboundRow(Long loadingPlanId,String planNo,Long containerBookingId,String loadingPlanStatus,
|
||||
String cabinetNo,String containerNo,Long inboundId,Long orderId,String orderNo,String inboundCode,
|
||||
Long tenantId,String customerName,String taxMode) {}
|
||||
record PlanRow(Long loadingPlanId,String planNo,Long containerBookingId,String loadingPlanStatus,
|
||||
String cabinetNo,String containerNo) {}
|
||||
record ContainerRow(Long id,Long loadingPlanId,Long containerBookingId,String status,Long startedBy,
|
||||
String startedByName,LocalDateTime startedAt,Long completedBy,String completedByName,
|
||||
LocalDateTime completedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) {}
|
||||
|
||||
+31
-10
@@ -44,6 +44,9 @@ public class ContainerCustomsService {
|
||||
|
||||
@Transactional
|
||||
public ContainerView platformView(long planId) {
|
||||
List<InboundRow> rows = actualPlanRows(planId);
|
||||
PlanRow plan = rows.isEmpty() ? requiredPlan(planId) : plan(rows.get(0));
|
||||
if (!"COMPLETED".equals(plan.loadingPlanStatus())) return null;
|
||||
ContainerRow container = ensure(planId);
|
||||
return view(container, null, false);
|
||||
}
|
||||
@@ -155,6 +158,7 @@ public class ContainerCustomsService {
|
||||
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||
if (replay != null) return platformView(planId);
|
||||
ensure(planId);
|
||||
if (actualPlanRows(planId).isEmpty()) throw conflict("当前装柜计划没有实际货物,无需报关");
|
||||
ContainerRow locked = mapper.lockContainer(planId);
|
||||
if (locked == null || !READY.equals(locked.status())) throw conflict("当前柜报关资料尚未齐全或已开始报关");
|
||||
if (!Objects.equals(locked.version(), request.expectedVersion())) throw concurrent();
|
||||
@@ -219,7 +223,8 @@ public class ContainerCustomsService {
|
||||
ContainerRow container = mapper.lockContainer(planId);
|
||||
if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中");
|
||||
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
|
||||
List<InboundRow> actual=planRows(planId);
|
||||
List<InboundRow> actual=actualPlanRows(planId);
|
||||
if(actual.isEmpty())throw conflict("当前装柜计划没有实际货物,无需报关");
|
||||
if(actual.stream().anyMatch(row->!"BUY_DECLARATION".equals(row.taxMode())))throw conflict("全买单柜完成只允许全部实际行均为BUY_DECLARATION");
|
||||
completeContainer(container, operatorId, operatorName, request.remark());
|
||||
completeCommand(operatorId, action, key, container.id());
|
||||
@@ -245,11 +250,11 @@ public class ContainerCustomsService {
|
||||
}
|
||||
|
||||
private ContainerRow ensure(long planId) {
|
||||
List<InboundRow> rows = planRows(planId);
|
||||
InboundRow first = rows.get(0);
|
||||
if (!"COMPLETED".equals(first.loadingPlanStatus())) throw conflict("装柜完成后才能处理报关资料");
|
||||
List<InboundRow> rows = actualPlanRows(planId);
|
||||
PlanRow plan = rows.isEmpty() ? requiredPlan(planId) : plan(rows.get(0));
|
||||
if (!"COMPLETED".equals(plan.loadingPlanStatus())) throw conflict("装柜完成后才能处理报关资料");
|
||||
boolean refund = rows.stream().anyMatch(row -> "TAX_REFUND".equals(row.taxMode()));
|
||||
mapper.insertContainer(IdWorker.getId(), planId, first.containerBookingId(), refund ? WAITING : READY);
|
||||
mapper.insertContainer(IdWorker.getId(), planId, plan.containerBookingId(), refund ? WAITING : READY);
|
||||
return refresh(planId);
|
||||
}
|
||||
|
||||
@@ -283,11 +288,27 @@ public class ContainerCustomsService {
|
||||
}
|
||||
|
||||
private List<InboundRow> planRows(long planId) {
|
||||
List<InboundRow> rows = mapper.planInbounds(planId);
|
||||
List<InboundRow> rows = actualPlanRows(planId);
|
||||
if (rows == null || rows.isEmpty()) throw notFound("未找到当前装柜计划的实际货物明细");
|
||||
return rows;
|
||||
}
|
||||
|
||||
private List<InboundRow> actualPlanRows(long planId) {
|
||||
List<InboundRow> rows = mapper.planInbounds(planId);
|
||||
return rows == null ? List.of() : rows;
|
||||
}
|
||||
|
||||
private PlanRow requiredPlan(long planId) {
|
||||
PlanRow plan = mapper.plan(planId);
|
||||
if (plan == null) throw notFound("装柜计划不存在或不可用");
|
||||
return plan;
|
||||
}
|
||||
|
||||
private PlanRow plan(InboundRow row) {
|
||||
return new PlanRow(row.loadingPlanId(), row.planNo(), row.containerBookingId(), row.loadingPlanStatus(),
|
||||
row.cabinetNo(), row.containerNo());
|
||||
}
|
||||
|
||||
private List<InboundRow> validateScope(long planId, long tenantId, List<Long> inboundIds) {
|
||||
if (inboundIds == null || inboundIds.isEmpty() || inboundIds.size() > 200) throw bad("至少选择一个且最多200个入仓码");
|
||||
Set<Long> requested = new LinkedHashSet<>(inboundIds);
|
||||
@@ -304,7 +325,8 @@ public class ContainerCustomsService {
|
||||
}
|
||||
|
||||
private ContainerView view(ContainerRow container, Long tenantId, boolean merchant) {
|
||||
List<InboundRow> rows = planRows(container.loadingPlanId());
|
||||
List<InboundRow> rows = actualPlanRows(container.loadingPlanId());
|
||||
PlanRow plan = rows.isEmpty() ? requiredPlan(container.loadingPlanId()) : plan(rows.get(0));
|
||||
List<DocumentRow> documents = mapper.documents(container.id(), tenantId);
|
||||
Set<Long> covered = documents.stream().flatMap(doc -> mapper.scopes(doc.id()).stream()).map(ScopeRow::inboundId).collect(Collectors.toSet());
|
||||
List<InboundRow> visibleRows=rows.stream().filter(row -> tenantId == null || (row.tenantId().equals(tenantId)&&"TAX_REFUND".equals(row.taxMode()))).toList();
|
||||
@@ -315,9 +337,8 @@ public class ContainerCustomsService {
|
||||
int refund = (int) visibleRows.stream().filter(row -> "TAX_REFUND".equals(row.taxMode())).count();
|
||||
int coveredRefund = (int) visibleRows.stream().filter(row -> covered.contains(row.inboundId())).count();
|
||||
String merchantStatus=tenantId==null?null:merchantStatus(container.status(),refund,coveredRefund);
|
||||
InboundRow first = rows.get(0);
|
||||
return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), first.planNo(),
|
||||
first.cabinetNo(), first.containerNo(), container.status(), merchantStatus, container.version(), refund, coveredRefund,
|
||||
return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), plan.planNo(),
|
||||
plan.cabinetNo(), plan.containerNo(), container.status(), !rows.isEmpty(), merchantStatus, container.version(), refund, coveredRefund,
|
||||
inbounds, documentViews, container.startedAt(), container.completedAt());
|
||||
}
|
||||
|
||||
|
||||
@@ -60,8 +60,10 @@ public final class CustomerDimensionConfigDtos {
|
||||
public record InitializeRequest(
|
||||
@NotNull @Min(1) Long tenantId,
|
||||
@NotNull @Min(0) Long operatorId,
|
||||
@NotBlank @Size(max = 128) String initializationKey
|
||||
@NotBlank @Size(max = 128) String initializationKey,
|
||||
@NotBlank @Size(max = 128) String customerName
|
||||
) { }
|
||||
|
||||
public record InitializeResponse(Long configId, boolean createdByThisInitialization) { }
|
||||
public record InitializeResponse(Long configId, Long chargeRuleId,
|
||||
boolean createdByThisInitialization) { }
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.baiyue.firstleg.dto;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
|
||||
/** 稳定箱号分配、补打和测量解析契约。 */
|
||||
public final class InboundCartonLabels {
|
||||
private InboundCartonLabels() {
|
||||
@@ -12,6 +14,9 @@ public final class InboundCartonLabels {
|
||||
List<Label> labels) {
|
||||
}
|
||||
|
||||
public record PlatformBatchItem(@Min(1) long tenantId, @Min(1) long inboundId) {
|
||||
}
|
||||
|
||||
public record Label(Long id, Long inboundDetailId, Integer cargoItemNo, String cargoName,
|
||||
Integer detailPackageCount, String boxCode, Integer totalCount,
|
||||
Integer cartonIndex, String boxSpecification, BigDecimal lengthCm,
|
||||
|
||||
@@ -87,6 +87,26 @@ public final class InboundMeasurementRequests {
|
||||
) {
|
||||
}
|
||||
|
||||
public record UpdateCalculationStrategyRequest(
|
||||
@NotNull @Positive Long measurementId,
|
||||
@NotNull @PositiveOrZero Integer version,
|
||||
@NotBlank @jakarta.validation.constraints.Pattern(regexp = "ACTUAL|ADD|MULTIPLY") String coefficientMode,
|
||||
@Positive Long chargeRuleId
|
||||
) {
|
||||
}
|
||||
|
||||
public record UpdateOuterDimensionsRequest(
|
||||
@NotNull @Positive Long measurementId,
|
||||
@NotNull @PositiveOrZero Integer version,
|
||||
@DecimalMin("0.001") @DecimalMax("999999999.999")
|
||||
@Digits(integer = 9, fraction = 3) BigDecimal outerLengthCm,
|
||||
@DecimalMin("0.001") @DecimalMax("999999999.999")
|
||||
@Digits(integer = 9, fraction = 3) BigDecimal outerWidthCm,
|
||||
@DecimalMin("0.001") @DecimalMax("999999999.999")
|
||||
@Digits(integer = 9, fraction = 3) BigDecimal outerHeightCm
|
||||
) {
|
||||
}
|
||||
|
||||
public record ConfirmedPackageCountRequest(
|
||||
@NotNull @PositiveOrZero Integer measurementVersion,
|
||||
@NotNull @Positive @Max(1000000) Integer confirmedPackageCount
|
||||
|
||||
@@ -183,6 +183,16 @@ public final class InboundMeasurementResponses {
|
||||
public record DraftSaved(Long measurementId, Integer version, String status) {
|
||||
}
|
||||
|
||||
public record CalculationStrategyUpdated(
|
||||
Long measurementId, Integer version, String status, String coefficientMode, Long chargeRuleId
|
||||
) {
|
||||
}
|
||||
|
||||
public record OuterDimensionsUpdated(
|
||||
Long measurementId, Integer version, String status, Long itemId
|
||||
) {
|
||||
}
|
||||
|
||||
public record MeasurementSubmitted(Long measurementId, Integer version, String processStatus,
|
||||
String measureReviewStatus,
|
||||
@Schema(description = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘",
|
||||
|
||||
@@ -38,4 +38,6 @@ public class CustomerChargeRuleEntity extends BaseEntity {
|
||||
private LocalDateTime effectiveTo;
|
||||
private String status;
|
||||
private String remark;
|
||||
/** 客户创建执行器的幂等键;人工创建的规则为空。 */
|
||||
private String initializationKey;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.CompletionTarget;
|
||||
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
|
||||
import com.baiyue.firstleg.loading.repository.LoadingRepository;
|
||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import com.baiyue.firstleg.service.FbaPricingService;
|
||||
import com.baiyue.firstleg.service.InboundHoldGuard;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -42,7 +41,6 @@ public class LoadingDeliveryService {
|
||||
@Resource private LoadingCompletionGateService completionGate;
|
||||
@Resource private InboundStatusHistoryMapper histories;
|
||||
@Resource private CabinetLoadingEvidenceService cabinetEvidenceService;
|
||||
@Resource private FbaPricingService pricingService;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private InboundHoldGuard inboundHoldGuard;
|
||||
|
||||
@@ -69,7 +67,6 @@ public class LoadingDeliveryService {
|
||||
}
|
||||
if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) {
|
||||
requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion);
|
||||
pricingService.autoConfirmAfterLoading(tenantId, inboundId);
|
||||
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
|
||||
new LoadingActionResponse(inbound.processStatus()));
|
||||
}
|
||||
@@ -80,7 +77,6 @@ public class LoadingDeliveryService {
|
||||
requireCabinetEvidence(tenantId, inboundId);
|
||||
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
|
||||
addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName);
|
||||
pricingService.autoConfirmAfterLoading(tenantId, inboundId);
|
||||
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
|
||||
new LoadingActionResponse("LOADED"));
|
||||
}
|
||||
@@ -124,7 +120,6 @@ public class LoadingDeliveryService {
|
||||
CompletionTarget target,
|
||||
boolean acceptedPdaCompletion) {
|
||||
if ("LOADED".equals(target.loadedChildStatus())) {
|
||||
pricingService.autoConfirmAfterLoading(tenantId, target.completionInboundId());
|
||||
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
|
||||
new LoadingActionResponse("LOADED"));
|
||||
}
|
||||
@@ -140,7 +135,6 @@ public class LoadingDeliveryService {
|
||||
repository.aggregateRootLoaded(tenantId, target.rootInboundId(), operatorId);
|
||||
addHistory(tenantId, target.completionInboundId(), "LOADING", "LOADED", COMPLETE_LOADING,
|
||||
request.remark(), operatorId, operatorName);
|
||||
pricingService.autoConfirmAfterLoading(tenantId, target.completionInboundId());
|
||||
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
|
||||
new LoadingActionResponse("LOADED"));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,50 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
|
||||
@Mapper
|
||||
public interface CustomerChargeRuleMapper extends BaseMapper<CustomerChargeRuleEntity> {}
|
||||
public interface CustomerChargeRuleMapper extends BaseMapper<CustomerChargeRuleEntity> {
|
||||
@Select("""
|
||||
SELECT id, tenant_id, rule_code, rule_version, customer_id, customer_name, business_type,
|
||||
charge_mode, tax_mode, conversion_factor, length_factor, width_factor, height_factor,
|
||||
default_rule, rounding_scale, rounding_mode, currency, effective_from, effective_to,
|
||||
status, remark, initialization_key, created_by, created_at, updated_by, updated_at,
|
||||
deleted, version
|
||||
FROM fl_customer_charge_rule
|
||||
WHERE tenant_id = #{tenantId} AND initialization_key = #{initializationKey} AND deleted = 0
|
||||
LIMIT 1
|
||||
""")
|
||||
CustomerChargeRuleEntity selectByInitializationKey(
|
||||
@Param("tenantId") long tenantId, @Param("initializationKey") String initializationKey);
|
||||
|
||||
@Select("""
|
||||
SELECT id, tenant_id, rule_code, rule_version, customer_id, customer_name, business_type,
|
||||
charge_mode, tax_mode, conversion_factor, length_factor, width_factor, height_factor,
|
||||
default_rule, rounding_scale, rounding_mode, currency, effective_from, effective_to,
|
||||
status, remark, initialization_key, created_by, created_at, updated_by, updated_at,
|
||||
deleted, version
|
||||
FROM fl_customer_charge_rule
|
||||
WHERE tenant_id = #{tenantId} AND initialization_key = #{initializationKey} AND deleted = 0
|
||||
LIMIT 1 FOR UPDATE
|
||||
""")
|
||||
CustomerChargeRuleEntity selectByInitializationKeyForUpdate(
|
||||
@Param("tenantId") long tenantId, @Param("initializationKey") String initializationKey);
|
||||
|
||||
@Delete("""
|
||||
DELETE FROM fl_customer_charge_rule
|
||||
WHERE tenant_id = #{tenantId} AND initialization_key = #{initializationKey}
|
||||
AND rule_code = 'DEFAULT-DIMENSION-WEIGHT' AND rule_version = 1
|
||||
AND customer_id = #{tenantId} AND business_type = 'FBA' AND charge_mode = 'CBM'
|
||||
AND tax_mode = 'TAX_INCLUDED' AND conversion_factor = 167.000000
|
||||
AND length_factor = 1.100000 AND width_factor = 1.100000 AND height_factor = 1.100000
|
||||
AND default_rule = 1 AND rounding_scale = 2 AND rounding_mode = 'HALF_UP'
|
||||
AND currency = 'CNY' AND effective_to IS NULL AND status = 'ACTIVE'
|
||||
AND deleted = 0 AND version = 0
|
||||
""")
|
||||
int deleteUnchangedInitializedRule(
|
||||
@Param("tenantId") long tenantId, @Param("initializationKey") String initializationKey);
|
||||
}
|
||||
|
||||
@@ -150,15 +150,13 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
|
||||
JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id
|
||||
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
|
||||
AND plan.id = pick_task.loading_plan_id
|
||||
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
|
||||
JOIN fl_inbound_order inbound_order ON inbound_order.id = plan_item.inbound_id
|
||||
AND inbound_order.tenant_id = plan_item.account_id AND inbound_order.deleted = 0
|
||||
WHERE pick_item.pick_task_id = #{taskId} AND booking.warehouse_id = #{warehouseId}
|
||||
AND plan_item.deleted = 0
|
||||
WHERE pick_item.pick_task_id = #{taskId} AND plan_item.deleted = 0
|
||||
ORDER BY pick_item.id ASC
|
||||
""")
|
||||
java.util.List<LoadingPickItemEntity> selectTaskItemsWithAccount(@Param("taskId") long taskId,
|
||||
@Param("warehouseId") long warehouseId);
|
||||
@Param("warehouseId") long warehouseId);
|
||||
|
||||
@Select("""
|
||||
SELECT pick_item.*, plan_item.unit_type, plan_item.pallet_id, plan_item.pallet_no, plan_item.indivisible
|
||||
@@ -167,11 +165,10 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
|
||||
JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id
|
||||
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
|
||||
AND plan.id = pick_task.loading_plan_id
|
||||
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
|
||||
JOIN fl_inbound_order inbound_order ON inbound_order.id = plan_item.inbound_id
|
||||
AND inbound_order.tenant_id = plan_item.account_id AND inbound_order.deleted = 0
|
||||
WHERE pick_item.pick_task_id = #{taskId} AND pick_item.id = #{itemId}
|
||||
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0
|
||||
AND plan_item.deleted = 0
|
||||
""")
|
||||
LoadingPickItemEntity selectTaskItemWithAccount(@Param("taskId") long taskId,
|
||||
@Param("itemId") long itemId,
|
||||
@@ -185,10 +182,8 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
|
||||
version = version + 1
|
||||
WHERE id = #{id} AND version = #{version}
|
||||
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
|
||||
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
|
||||
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
|
||||
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
|
||||
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
|
||||
AND plan_item.deleted = 0)
|
||||
AND picked_package_count + #{count} <= planned_package_count
|
||||
""")
|
||||
int pick(@Param("id") long id, @Param("count") int count,
|
||||
@@ -201,10 +196,8 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
|
||||
version = version + 1
|
||||
WHERE id = #{id} AND version = #{version}
|
||||
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
|
||||
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
|
||||
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
|
||||
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
|
||||
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
|
||||
AND plan_item.deleted = 0)
|
||||
AND item_status IN ('PENDING', 'PICKING')
|
||||
AND picked_package_count < #{finalCount}
|
||||
AND #{finalCount} < planned_package_count
|
||||
@@ -218,13 +211,12 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
|
||||
SET item_status = 'SKIPPED', version = version + 1
|
||||
WHERE id = #{id} AND version = #{version}
|
||||
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
|
||||
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
|
||||
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
|
||||
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
|
||||
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
|
||||
AND plan_item.deleted = 0)
|
||||
AND item_status = 'PENDING' AND picked_package_count = 0
|
||||
""")
|
||||
int skip(@Param("id") long id, @Param("version") int version, @Param("warehouseId") long warehouseId);
|
||||
int skip(@Param("id") long id, @Param("version") int version,
|
||||
@Param("warehouseId") long warehouseId);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*)
|
||||
|
||||
@@ -50,7 +50,6 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
|
||||
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
|
||||
WHERE booking.container_no = #{containerNo} AND booking.record_status = 'ACTIVE'
|
||||
AND task.task_status = 'LOADING'
|
||||
AND booking.warehouse_id = #{warehouseId}
|
||||
LIMIT 1
|
||||
""")
|
||||
LoadingPickTaskEntity selectLoadingByContainerAndAccount(@Param("containerNo") String containerNo,
|
||||
@@ -59,13 +58,13 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
|
||||
@Select("SELECT task.* FROM fl_loading_pick_task task JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id "
|
||||
+ "JOIN fl_container_booking booking ON booking.id=plan.container_booking_id "
|
||||
+ "WHERE task.task_status='LOADING' AND booking.record_status='ACTIVE' "
|
||||
+ "AND booking.cabinet_no=#{cabinetNo} AND booking.warehouse_id=#{warehouseId} LIMIT 1")
|
||||
+ "AND booking.cabinet_no=#{cabinetNo} LIMIT 1")
|
||||
LoadingPickTaskEntity selectLoadingByCabinet(@Param("cabinetNo") String cabinetNo, @Param("warehouseId") long warehouseId);
|
||||
|
||||
@Select("SELECT task.* FROM fl_loading_pick_task task JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id "
|
||||
+ "JOIN fl_container_booking booking ON booking.id=plan.container_booking_id "
|
||||
+ "WHERE task.task_status='LOADING' AND booking.record_status='ACTIVE' "
|
||||
+ "AND task.loading_plan_id=#{loadingPlanId} AND booking.warehouse_id=#{warehouseId} LIMIT 1")
|
||||
+ "AND task.loading_plan_id=#{loadingPlanId} LIMIT 1")
|
||||
LoadingPickTaskEntity selectLoadingByPlan(@Param("loadingPlanId") long loadingPlanId, @Param("warehouseId") long warehouseId);
|
||||
|
||||
@Select("SELECT booking.* FROM fl_loading_pick_task task "
|
||||
|
||||
+2
-2
@@ -97,7 +97,7 @@ public class OutboundDispatchService {
|
||||
|
||||
OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId);
|
||||
if (carrierSchedules != null) {
|
||||
if (ocean == null || ocean.id() == null || ocean.customerId() == null) {
|
||||
if (ocean == null || ocean.id() == null) {
|
||||
throw conflict("当前货柜未关联有效订舱,无法创建船司查询任务");
|
||||
}
|
||||
carrierSchedules.ensureFromOutboundConfirm(ocean.customerId(), ocean.id(), containerBookingId,
|
||||
@@ -129,7 +129,7 @@ public class OutboundDispatchService {
|
||||
if (!Objects.equals(visible.version(), request.expectedVersion())) throw concurrent();
|
||||
OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId);
|
||||
if (carrierSchedules != null) {
|
||||
if (ocean == null || ocean.id() == null || ocean.customerId() == null) {
|
||||
if (ocean == null || ocean.id() == null) {
|
||||
throw conflict("无法核验货柜后续船节点,禁止撤销国内出仓");
|
||||
}
|
||||
if (carrierSchedules.hasOutboundDepartureFact(ocean.customerId(), ocean.id(), containerBookingId)) {
|
||||
|
||||
+1
-2
@@ -111,8 +111,7 @@ public class PlatformCarrierScheduleQueryController {
|
||||
|
||||
@PostMapping("/{queryId}/manual-nodes")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
|
||||
@Operation(summary = "人工补录预计或实际标准船节点",
|
||||
description = "预计节点不推进实际进度;人工实际节点必须严格晚于当前最新实际节点,同级或更早节点返回 A0409。")
|
||||
@Operation(summary = "在跟踪任务内人工补录预计或实际标准船节点")
|
||||
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY",
|
||||
resourceId = "#queryId", description = "人工补录船节点事实")
|
||||
public ApiResponse<Detail> manualNode(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.baiyue.firstleg.portschedule.controller;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerNodeDetail;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
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.*;
|
||||
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/container-shipping-nodes")
|
||||
public class PlatformContainerShippingNodeController {
|
||||
@Resource private CarrierScheduleQueryService service;
|
||||
|
||||
@GetMapping("/{containerBookingId}")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')")
|
||||
public ApiResponse<ContainerNodeDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long containerBookingId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.containerNodeDetail(containerBookingId));
|
||||
}
|
||||
|
||||
@PostMapping("/{containerBookingId}")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
|
||||
@BusinessAudit(event = "CONTAINER_SHIPPING_NODE_MANUAL", resourceType = "CONTAINER_BOOKING",
|
||||
resourceId = "#containerBookingId", description = "人工修改货柜当前船节点")
|
||||
public ApiResponse<ContainerNodeDetail> add(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long containerBookingId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ContainerManualNodeRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.addContainerManualNode(containerBookingId, user.userId(),
|
||||
user.username(), key, request));
|
||||
}
|
||||
}
|
||||
+13
-2
@@ -13,7 +13,7 @@ import java.util.List;
|
||||
public final class CarrierScheduleQueryDtos {
|
||||
private CarrierScheduleQueryDtos() {}
|
||||
|
||||
public record Query(@Positive Long customerId, @Positive Long oceanBookingId,
|
||||
public record Query(@Min(0) Long customerId, @Positive Long oceanBookingId,
|
||||
@Positive Long containerBookingId, @Size(max = 16) String carrierCode,
|
||||
@Size(max = 24) String status, Boolean expired,
|
||||
@Size(max = 128) String keyword, @Min(1) Long page,
|
||||
@@ -22,7 +22,7 @@ public final class CarrierScheduleQueryDtos {
|
||||
}
|
||||
|
||||
public record CreateRequest(@NotNull @Min(0) @Max(0) Integer expectedVersion,
|
||||
@NotNull @Positive Long customerId,
|
||||
@NotNull @Min(0) Long customerId,
|
||||
@NotNull @Positive Long oceanBookingId,
|
||||
@NotNull @Positive Long containerBookingId,
|
||||
@NotBlank @Size(max = 16) String carrierCode,
|
||||
@@ -43,6 +43,14 @@ public final class CarrierScheduleQueryDtos {
|
||||
@Size(max = 128) String locationName,
|
||||
@NotBlank @Size(max = 1000) String reason) {}
|
||||
|
||||
public record ContainerManualNodeRequest(@NotBlank @Size(max = 32) String standardNode,
|
||||
@NotNull LocalDateTime observedAt,
|
||||
LocalDateTime estimatedAt,
|
||||
LocalDateTime actualAt,
|
||||
@Size(max = 32) String locationCode,
|
||||
@Size(max = 128) String locationName,
|
||||
@NotBlank @Size(max = 1000) String reason) {}
|
||||
|
||||
public record ProviderObservationRequest(@NotBlank @Size(max = 32) String providerCode,
|
||||
@NotBlank @Size(max = 128) String providerEventId,
|
||||
@NotBlank @Size(max = 64) String providerNodeCode,
|
||||
@@ -97,6 +105,9 @@ public final class CarrierScheduleQueryDtos {
|
||||
LocalDateTime actualAt, String locationCode, String locationName,
|
||||
String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {}
|
||||
|
||||
public record ContainerNodeDetail(Long containerBookingId, String currentNode,
|
||||
LocalDateTime currentNodeAt, List<NodeFact> nodes) {}
|
||||
|
||||
public record ExceptionFact(Long id, String providerCode, String providerEventId,
|
||||
String providerStatus, String exceptionCode, boolean active,
|
||||
LocalDateTime observedAt, String locationCode, String locationName,
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ import java.time.LocalDateTime;
|
||||
public class CarrierScheduleNodeFactEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private Long queryId;
|
||||
private Long containerBookingId;
|
||||
private String eventKey;
|
||||
private String providerCode;
|
||||
private String providerEventId;
|
||||
|
||||
+12
-2
@@ -5,19 +5,29 @@ import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
|
||||
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 java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
|
||||
@Insert("""
|
||||
INSERT INTO fl_carrier_schedule_node_fact
|
||||
(id,query_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at,
|
||||
(id,query_id,container_booking_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at,
|
||||
estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at)
|
||||
VALUES
|
||||
(#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode},
|
||||
(#{value.id},#{value.queryId},#{value.containerBookingId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode},
|
||||
#{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt},
|
||||
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},
|
||||
CURRENT_TIMESTAMP(3))
|
||||
ON DUPLICATE KEY UPDATE id=id
|
||||
""")
|
||||
int insertIgnore(@Param("value") CarrierScheduleNodeFactEntity value);
|
||||
|
||||
@Select("SELECT * FROM fl_carrier_schedule_node_fact WHERE container_booking_id=#{containerBookingId} ORDER BY observed_at,id")
|
||||
List<CarrierScheduleNodeFactEntity> selectByContainer(@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@Select("SELECT * FROM fl_carrier_schedule_node_fact WHERE container_booking_id=#{containerBookingId} AND event_key=#{eventKey} LIMIT 1")
|
||||
CarrierScheduleNodeFactEntity selectByContainerEvent(@Param("containerBookingId") long containerBookingId,
|
||||
@Param("eventKey") String eventKey);
|
||||
}
|
||||
|
||||
+14
-14
@@ -49,26 +49,26 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT q.container_booking_id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
|
||||
q.status AS queryStatus,q.latest_standard_node AS latestActualNode,
|
||||
q.latest_node_observed_at AS latestActualAt,
|
||||
SELECT b.id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
|
||||
q.status AS queryStatus,current.standard_node AS latestActualNode,
|
||||
current.actual_at AS latestActualAt,
|
||||
MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE' THEN f.actual_at END) AS departedWarehouseAt,
|
||||
MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) AS departedOriginPortAt,
|
||||
MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT' THEN f.actual_at END) AS arrivedDestinationPortAt,
|
||||
MAX(CASE WHEN f.standard_node='PICKUP_READY' THEN f.actual_at END) AS pickupReadyAt,
|
||||
MAX(CASE WHEN f.standard_node='PICKED_UP' THEN f.actual_at END) AS pickedUpAt,
|
||||
MAX(CASE WHEN f.standard_node='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt
|
||||
FROM fl_carrier_schedule_query q
|
||||
JOIN (
|
||||
SELECT container_booking_id,MAX(id) AS query_id
|
||||
FROM fl_carrier_schedule_query
|
||||
WHERE container_booking_id IN
|
||||
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
GROUP BY container_booking_id
|
||||
) latest ON latest.query_id=q.id
|
||||
LEFT JOIN fl_carrier_schedule_node_fact f ON f.query_id=q.id AND f.actual_at IS NOT NULL
|
||||
GROUP BY q.container_booking_id,q.id,q.version,q.status,q.latest_standard_node,q.latest_node_observed_at
|
||||
ORDER BY q.container_booking_id
|
||||
FROM fl_container_booking b
|
||||
JOIN fl_carrier_schedule_node_fact current ON current.id=(
|
||||
SELECT f2.id FROM fl_carrier_schedule_node_fact f2
|
||||
WHERE f2.container_booking_id=b.id AND f2.actual_at IS NOT NULL
|
||||
ORDER BY f2.observed_at DESC,f2.id DESC LIMIT 1)
|
||||
LEFT JOIN fl_carrier_schedule_query q ON q.id=current.query_id
|
||||
LEFT JOIN fl_carrier_schedule_node_fact f ON f.container_booking_id=b.id AND f.actual_at IS NOT NULL
|
||||
WHERE b.id IN
|
||||
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
GROUP BY b.id,q.id,q.version,q.status,current.standard_node,current.actual_at
|
||||
ORDER BY b.id
|
||||
</script>
|
||||
""")
|
||||
List<ContainerActualFacts> selectContainerActualFacts(
|
||||
|
||||
+6
@@ -79,6 +79,12 @@ public class CarrierScheduleQueryRepository {
|
||||
.eq(CarrierScheduleNodeFactEntity::getQueryId, queryId)
|
||||
.eq(CarrierScheduleNodeFactEntity::getEventKey, eventKey));
|
||||
}
|
||||
public List<CarrierScheduleNodeFactEntity> nodesByContainer(long containerBookingId) {
|
||||
return nodes.selectByContainer(containerBookingId);
|
||||
}
|
||||
public CarrierScheduleNodeFactEntity nodeByContainer(long containerBookingId, String eventKey) {
|
||||
return nodes.selectByContainerEvent(containerBookingId, eventKey);
|
||||
}
|
||||
public boolean hasActualNode(long queryId, String standardNode) {
|
||||
return nodes.selectCount(new LambdaQueryWrapper<CarrierScheduleNodeFactEntity>()
|
||||
.eq(CarrierScheduleNodeFactEntity::getQueryId, queryId)
|
||||
|
||||
+18
-6
@@ -42,28 +42,40 @@ public class CarrierScheduleNodeOutboxService {
|
||||
|| !normalizedContainerNo.equals(query.getTrackingNo())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "船节点任务跟踪柜号与跨区事件柜号不一致");
|
||||
}
|
||||
publish(query.getId(), query.getOceanBookingId(), query.getContainerBookingId(), query.getVersion(),
|
||||
fact, normalizedContainerNo, "CARRIER_NODE:" + query.getId() + ':' + fact.getId());
|
||||
}
|
||||
|
||||
public void publishContainerNode(Long oceanBookingId, long containerBookingId,
|
||||
CarrierScheduleNodeFactEntity fact, String containerNo) {
|
||||
if (fact.getActualAt() == null) return;
|
||||
String normalizedContainerNo = ContainerNumbers.normalizeRequired(containerNo);
|
||||
publish(null, oceanBookingId, containerBookingId, 0, fact, normalizedContainerNo,
|
||||
"CONTAINER_NODE:" + containerBookingId + ':' + fact.getId());
|
||||
}
|
||||
|
||||
private void publish(Long queryId, Long oceanBookingId, Long containerBookingId, Integer version,
|
||||
CarrierScheduleNodeFactEntity fact, String normalizedContainerNo, String eventKey) {
|
||||
ZoneId zone = ZoneId.of(scheduleZone);
|
||||
var payload = new FirstLegContainerShippingNodeRecordedV1(
|
||||
string(query.getId()), string(query.getOceanBookingId()),
|
||||
string(query.getContainerBookingId()), normalizedContainerNo,
|
||||
string(queryId), string(oceanBookingId), string(containerBookingId), normalizedContainerNo,
|
||||
fact.getStandardNode(), fact.getNodeSequence(),
|
||||
fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(),
|
||||
fact.getLocationCode(), fact.getLocationName(), fact.getProviderCode(), fact.getProviderEventId(),
|
||||
fact.getProviderNodeCode(), fact.getSourceReference(), fact.getRawPayloadHash());
|
||||
JsonNode payloadNode = objectMapper.valueToTree(payload);
|
||||
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
|
||||
long aggregateVersion = Math.max(0, query.getVersion() == null ? 0 : query.getVersion());
|
||||
String eventKey = "CARRIER_NODE:" + query.getId() + ':' + fact.getId();
|
||||
long aggregateVersion = Math.max(0, version == null ? 0 : version);
|
||||
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
|
||||
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1,
|
||||
fact.getActualAt().atZone(zone).toInstant(), RegionCode.CN, RegionCode.OVERSEAS,
|
||||
"0", "CONTAINER_BOOKING", string(query.getContainerBookingId()),
|
||||
"0", "CONTAINER_BOOKING", string(containerBookingId),
|
||||
aggregateVersion, aggregateVersion + 1, EventIdentifiers.newUuid(), eventKey, null,
|
||||
payloadNode, payloadHash).requireValid();
|
||||
|
||||
InboundOutboxEntity row = new InboundOutboxEntity();
|
||||
row.setId(IdWorker.getId()); row.setTenantId(0L);
|
||||
row.setAggregateType(envelope.aggregateType()); row.setAggregateId(query.getContainerBookingId());
|
||||
row.setAggregateType(envelope.aggregateType()); row.setAggregateId(containerBookingId);
|
||||
row.setEventType(envelope.eventType()); row.setEventKey(eventKey); row.setEventVersion(1);
|
||||
row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash);
|
||||
row.setPayloadJson(json(envelope)); row.setPublishStatus("PENDING"); row.setRetryCount(0);
|
||||
|
||||
+64
-20
@@ -8,6 +8,8 @@ import com.baiyue.common.event.ContainerNumbers;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerNodeDetail;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
|
||||
@@ -48,6 +50,7 @@ import java.security.MessageDigest;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
@@ -101,15 +104,17 @@ public class CarrierScheduleQueryService {
|
||||
|
||||
/** Idempotently starts the canonical tracking task after domestic outbound confirmation. */
|
||||
@Transactional
|
||||
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(long customerId, long oceanBookingId,
|
||||
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId,
|
||||
long containerBookingId,
|
||||
long operatorId, String operatorName) {
|
||||
CarrierScheduleQueryEntity existing = repository.findByBusinessScope(customerId, oceanBookingId,
|
||||
containerBookingId);
|
||||
OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId);
|
||||
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
|
||||
if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在");
|
||||
validateLinks(customerId, oceanBookingId, containerBookingId);
|
||||
long scopeCustomerId = booking.getCustomerId() == null ? 0L : booking.getCustomerId();
|
||||
if (customerId != null && customerId != scopeCustomerId) throw badRequest("customerId与订舱客户不一致");
|
||||
CarrierScheduleQueryEntity existing = repository.findByBusinessScope(scopeCustomerId, oceanBookingId,
|
||||
containerBookingId);
|
||||
validateLinks(scopeCustomerId, oceanBookingId, containerBookingId);
|
||||
String trackingType = "CONTAINER_NO";
|
||||
String trackingNo = normalizedContainerNo(container);
|
||||
if (existing != null) {
|
||||
@@ -120,7 +125,7 @@ public class CarrierScheduleQueryService {
|
||||
LocalDateTime now = now();
|
||||
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
|
||||
value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId());
|
||||
value.setCustomerId(customerId); value.setOceanBookingId(oceanBookingId);
|
||||
value.setCustomerId(scopeCustomerId); value.setOceanBookingId(oceanBookingId);
|
||||
value.setContainerBookingId(containerBookingId); value.setCarrierCode(strategy.carrierCode());
|
||||
value.setTrackingType(trackingType); value.setTrackingNo(trackingNo);
|
||||
value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode());
|
||||
@@ -136,7 +141,7 @@ public class CarrierScheduleQueryService {
|
||||
try {
|
||||
try { repository.insert(value); }
|
||||
catch (DuplicateKeyException exception) {
|
||||
CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId,
|
||||
CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(scopeCustomerId, oceanBookingId,
|
||||
containerBookingId);
|
||||
if (concurrent != null) {
|
||||
requireFrozenTrackingIdentity(concurrent, trackingNo);
|
||||
@@ -145,7 +150,7 @@ public class CarrierScheduleQueryService {
|
||||
throw exception;
|
||||
}
|
||||
persistenceStage = "NODE_FACT";
|
||||
CarrierScheduleNodeFactEntity departed = addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
|
||||
CarrierScheduleNodeFactEntity departed = addNode(value, "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
|
||||
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
|
||||
sha256("OUTBOUND_CONFIRM|" + containerBookingId));
|
||||
if (departed != null) {
|
||||
@@ -176,7 +181,7 @@ public class CarrierScheduleQueryService {
|
||||
CarrierScheduleQueryEntity value = requiredLocked(id);
|
||||
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
|
||||
CarrierScheduleQueryEntity before = copy(value);
|
||||
CarrierScheduleNodeFactEntity fact = addNode(id, request.providerCode(), request.providerEventId(),
|
||||
CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(),
|
||||
request.providerNodeCode(), node,
|
||||
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
|
||||
request.locationName(), request.sourceReference(), request.rawPayloadHash());
|
||||
@@ -217,10 +222,9 @@ public class CarrierScheduleQueryService {
|
||||
}
|
||||
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
|
||||
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
|
||||
requireManualActualAdvance(value, node, request.actualAt());
|
||||
CarrierScheduleQueryEntity before = copy(value);
|
||||
String providerEventId = sha256("MANUAL|" + id + '|' + key);
|
||||
CarrierScheduleNodeFactEntity fact = addNode(id, "MANUAL", providerEventId, node.name(), node,
|
||||
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
|
||||
request.observedAt(),
|
||||
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
|
||||
"MANUAL:" + operatorId, hash(operation, id, request));
|
||||
@@ -239,13 +243,42 @@ public class CarrierScheduleQueryService {
|
||||
return complete(id, operatorId, key, detail(after));
|
||||
}
|
||||
|
||||
private void requireManualActualAdvance(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
|
||||
LocalDateTime actualAt) {
|
||||
if (actualAt == null || value.getLatestStandardNode() == null) return;
|
||||
CarrierScheduleNode current = CarrierScheduleNode.require(value.getLatestStandardNode());
|
||||
if (node.sequence() <= current.sequence()) {
|
||||
throw conflict("人工实际节点必须严格晚于当前最新实际节点");
|
||||
public ContainerNodeDetail containerNodeDetail(long containerBookingId) {
|
||||
if (portSchedules.containerBooking(containerBookingId) == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
|
||||
}
|
||||
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ContainerNodeDetail addContainerManualNode(long containerBookingId, long operatorId,
|
||||
String operatorName, String key,
|
||||
ContainerManualNodeRequest request) {
|
||||
validateKey(key);
|
||||
if (request.estimatedAt() == null && request.actualAt() == null) {
|
||||
throw badRequest("estimatedAt和actualAt至少提供一个");
|
||||
}
|
||||
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
|
||||
if (container == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
|
||||
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
|
||||
String providerEventId = sha256("CONTAINER_MANUAL|" + containerBookingId + '|' + key);
|
||||
String payloadHash = hash("CONTAINER_MANUAL_NODE", containerBookingId, request);
|
||||
CarrierScheduleNodeFactEntity fact = addNode(null, containerBookingId, "MANUAL", providerEventId,
|
||||
node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(),
|
||||
request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash);
|
||||
if (fact != null) nodeOutbox.publishContainerNode(container.getOceanBookingId(), containerBookingId,
|
||||
fact, normalizedContainerNo(container));
|
||||
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
|
||||
}
|
||||
|
||||
private ContainerNodeDetail containerNodeDetail(long containerBookingId,
|
||||
List<CarrierScheduleNodeFactEntity> facts) {
|
||||
CarrierScheduleNodeFactEntity current = facts.stream()
|
||||
.max(Comparator.comparing(CarrierScheduleNodeFactEntity::getObservedAt)
|
||||
.thenComparing(CarrierScheduleNodeFactEntity::getId)).orElse(null);
|
||||
return new ContainerNodeDetail(containerBookingId, current == null ? null : current.getStandardNode(),
|
||||
current == null ? null : current.getActualAt() != null ? current.getActualAt() : current.getEstimatedAt(),
|
||||
facts.stream().map(this::node).toList());
|
||||
}
|
||||
|
||||
private boolean advanceActualNode(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
|
||||
@@ -424,7 +457,8 @@ public class CarrierScheduleQueryService {
|
||||
private void validateLinks(long customerId, long oceanBookingId, Long containerBookingId) {
|
||||
OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId);
|
||||
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "关联订舱不存在");
|
||||
if (!Objects.equals(booking.getCustomerId(), customerId)) throw badRequest("customerId与订舱客户不一致");
|
||||
long expectedCustomerId = booking.getCustomerId() == null ? 0L : booking.getCustomerId();
|
||||
if (expectedCustomerId != customerId) throw badRequest("customerId与订舱客户不一致");
|
||||
if (containerBookingId != null) {
|
||||
if (portSchedules.containerBooking(containerBookingId) == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "关联柜次不存在");
|
||||
@@ -490,13 +524,23 @@ public class CarrierScheduleQueryService {
|
||||
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
|
||||
}
|
||||
|
||||
private CarrierScheduleNodeFactEntity addNode(long queryId, String providerCode, String providerEventId,
|
||||
private CarrierScheduleNodeFactEntity addNode(CarrierScheduleQueryEntity query, String providerCode, String providerEventId,
|
||||
String providerNodeCode,
|
||||
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
||||
LocalDateTime actualAt, String locationCode, String locationName,
|
||||
String sourceReference, String rawPayloadHash) {
|
||||
return addNode(query.getId(), query.getContainerBookingId(), providerCode, providerEventId,
|
||||
providerNodeCode, node, observedAt, estimatedAt, actualAt, locationCode, locationName,
|
||||
sourceReference, rawPayloadHash);
|
||||
}
|
||||
|
||||
private CarrierScheduleNodeFactEntity addNode(Long queryId, long containerBookingId,
|
||||
String providerCode, String providerEventId, String providerNodeCode,
|
||||
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
||||
LocalDateTime actualAt, String locationCode, String locationName,
|
||||
String sourceReference, String rawPayloadHash) {
|
||||
CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity();
|
||||
fact.setId(IdWorker.getId()); fact.setQueryId(queryId);
|
||||
fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setContainerBookingId(containerBookingId);
|
||||
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
|
||||
fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT));
|
||||
fact.setProviderEventId(providerEventId.trim());
|
||||
@@ -505,7 +549,7 @@ public class CarrierScheduleQueryService {
|
||||
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
|
||||
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
|
||||
if (repository.insertIfAbsent(fact)) return fact;
|
||||
CarrierScheduleNodeFactEntity existing = repository.node(queryId, fact.getEventKey());
|
||||
CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey());
|
||||
if (existing != null && !Objects.equals(existing.getRawPayloadHash(), rawPayloadHash)) {
|
||||
throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有节点事实");
|
||||
}
|
||||
|
||||
@@ -55,6 +55,19 @@ public class FbaPricingRepository {
|
||||
|
||||
public void insertRule(CustomerChargeRuleEntity rule) { rules.insert(rule); }
|
||||
|
||||
public CustomerChargeRuleEntity findRuleByInitializationKey(long tenantId, String initializationKey) {
|
||||
return rules.selectByInitializationKey(tenantId, initializationKey);
|
||||
}
|
||||
|
||||
public CustomerChargeRuleEntity findRuleByInitializationKeyForUpdate(
|
||||
long tenantId, String initializationKey) {
|
||||
return rules.selectByInitializationKeyForUpdate(tenantId, initializationKey);
|
||||
}
|
||||
|
||||
public int deleteUnchangedInitializedRule(long tenantId, String initializationKey) {
|
||||
return rules.deleteUnchangedInitializedRule(tenantId, initializationKey);
|
||||
}
|
||||
|
||||
public int clearDefaultRule(long tenantId, long customerId, long operatorId) {
|
||||
return rules.update(null, new LambdaUpdateWrapper<CustomerChargeRuleEntity>()
|
||||
.eq(CustomerChargeRuleEntity::getTenantId, tenantId)
|
||||
@@ -285,22 +298,22 @@ public class FbaPricingRepository {
|
||||
.set(InboundFeeEntity::getUpdatedAt, LocalDateTime.now()));
|
||||
}
|
||||
|
||||
public int markInboundAdjusted(long tenantId, long inboundId, String feeStatus,
|
||||
public int markInboundAdjusted(long tenantId, long inboundId, String processStatus, String feeStatus,
|
||||
java.math.BigDecimal selectedWeightKg,
|
||||
java.math.BigDecimal selectedVolumeCbm,
|
||||
String chargeUnit, java.math.BigDecimal chargeableQuantity) {
|
||||
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId)
|
||||
.eq(InboundPricingEntity::getId, inboundId)
|
||||
.eq(InboundPricingEntity::getProcessStatus, "WAIT_SPLIT")
|
||||
.eq(InboundPricingEntity::getProcessStatus, processStatus)
|
||||
.set(InboundPricingEntity::getFeeStatus, feeStatus);
|
||||
if ("KG".equals(chargeUnit)) {
|
||||
update.set(InboundPricingEntity::getChargeableWeightKg, chargeableQuantity);
|
||||
} else {
|
||||
update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeableQuantity);
|
||||
}
|
||||
applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeUnit, chargeableQuantity);
|
||||
return inbounds.update(null, update);
|
||||
}
|
||||
|
||||
public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode,
|
||||
java.math.BigDecimal selectedWeightKg,
|
||||
java.math.BigDecimal selectedVolumeCbm,
|
||||
java.math.BigDecimal chargeable) {
|
||||
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId)
|
||||
@@ -308,15 +321,17 @@ public class FbaPricingRepository {
|
||||
.eq(InboundPricingEntity::getProcessStatus, processStatus)
|
||||
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED", "SENT"))
|
||||
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
|
||||
if (!List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING").contains(processStatus)) {
|
||||
if (!List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")
|
||||
.contains(processStatus)) {
|
||||
update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING");
|
||||
}
|
||||
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable);
|
||||
else update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeable);
|
||||
applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeMode, chargeable);
|
||||
return inbounds.update(null, update);
|
||||
}
|
||||
|
||||
public int markInboundContainerFeeRecalculated(long tenantId, long inboundId, String processStatus,
|
||||
java.math.BigDecimal selectedWeightKg,
|
||||
java.math.BigDecimal selectedVolumeCbm,
|
||||
String chargeMode, java.math.BigDecimal chargeable) {
|
||||
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId)
|
||||
@@ -324,11 +339,21 @@ public class FbaPricingRepository {
|
||||
.eq(InboundPricingEntity::getProcessStatus, processStatus)
|
||||
.in(InboundPricingEntity::getFeeStatus, List.of("CALCULATED", "CONFIRMED"))
|
||||
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
|
||||
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable);
|
||||
else update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeable);
|
||||
applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeMode, chargeable);
|
||||
return inbounds.update(null, update);
|
||||
}
|
||||
|
||||
private void applyChargeableSnapshot(LambdaUpdateWrapper<InboundPricingEntity> update,
|
||||
java.math.BigDecimal selectedWeightKg,
|
||||
java.math.BigDecimal selectedVolumeCbm,
|
||||
String chargeUnit, java.math.BigDecimal chargeableQuantity) {
|
||||
// 列表同时展示两项:当前计费单位保存最终计费量,另一项保存本次计价选定值。
|
||||
update.set(InboundPricingEntity::getChargeableWeightKg,
|
||||
"KG".equals(chargeUnit) ? chargeableQuantity : selectedWeightKg)
|
||||
.set(InboundPricingEntity::getChargeableVolumeCbm,
|
||||
"CBM".equals(chargeUnit) ? chargeableQuantity : selectedVolumeCbm);
|
||||
}
|
||||
|
||||
public int confirmFee(long tenantId, long feeId, long operatorId, LocalDateTime now) {
|
||||
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
|
||||
.eq(InboundFeeEntity::getTenantId, tenantId).eq(InboundFeeEntity::getId, feeId)
|
||||
|
||||
+8
@@ -71,6 +71,14 @@ public class InboundMeasurementRepository {
|
||||
.eq(InboundMeasurementEntity::getId, measurementId));
|
||||
}
|
||||
|
||||
public InboundMeasurementEntity lockMeasurement(long tenantId, long inboundId, long measurementId) {
|
||||
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
|
||||
.eq(InboundMeasurementEntity::getTenantId, tenantId)
|
||||
.eq(InboundMeasurementEntity::getInboundId, inboundId)
|
||||
.eq(InboundMeasurementEntity::getId, measurementId)
|
||||
.last("FOR UPDATE"));
|
||||
}
|
||||
|
||||
public InboundMeasurementEntity findLatestMeasurement(long tenantId, long inboundId) {
|
||||
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
|
||||
.eq(InboundMeasurementEntity::getTenantId, tenantId)
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
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.entity.CustomerChargeRuleEntity;
|
||||
import com.baiyue.firstleg.repository.FbaPricingRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 在客户创建执行器中幂等生成“箱规按权重系数计算”的默认规则。 */
|
||||
@Service
|
||||
public class CustomerChargeRuleInitializationService {
|
||||
static final String DEFAULT_RULE_CODE = "DEFAULT-DIMENSION-WEIGHT";
|
||||
static final BigDecimal DEFAULT_COEFFICIENT = new BigDecimal("1.100000");
|
||||
static final BigDecimal DEFAULT_CONVERSION_FACTOR = new BigDecimal("167.000000");
|
||||
static final String DEFAULT_REMARK = "SMALL:30,20,20;系数:1.1;重量:5;小箱:标准小箱;"
|
||||
+ "MEDIUM:40,30,30;系数:1.1;重量:8;中箱:标准中箱;"
|
||||
+ "LARGE:50,40,40;系数:1.1;重量:12;大箱:标准大箱;"
|
||||
+ "EXTRA_LARGE:60,50,50;系数:1.1;重量:15;特大箱:特大箱";
|
||||
|
||||
@Resource private FbaPricingRepository repository;
|
||||
|
||||
public CustomerChargeRuleEntity initializeDefault(long tenantId, long operatorId,
|
||||
String initializationKey, String customerName) {
|
||||
CustomerChargeRuleEntity existing = repository.findRuleByInitializationKey(
|
||||
tenantId, initializationKey);
|
||||
if (existing != null) return existing;
|
||||
|
||||
CustomerChargeRuleEntity value = new CustomerChargeRuleEntity();
|
||||
value.setId(IdWorker.getId());
|
||||
value.setTenantId(tenantId);
|
||||
value.setRuleCode(DEFAULT_RULE_CODE);
|
||||
value.setRuleVersion(1);
|
||||
value.setCustomerId(tenantId);
|
||||
value.setCustomerName(requireCustomerName(customerName));
|
||||
value.setBusinessType("FBA");
|
||||
value.setChargeMode("CBM");
|
||||
value.setTaxMode("TAX_INCLUDED");
|
||||
value.setConversionFactor(DEFAULT_CONVERSION_FACTOR);
|
||||
value.setLengthFactor(DEFAULT_COEFFICIENT);
|
||||
value.setWidthFactor(DEFAULT_COEFFICIENT);
|
||||
value.setHeightFactor(DEFAULT_COEFFICIENT);
|
||||
value.setDefaultRule(true);
|
||||
value.setRoundingScale(2);
|
||||
value.setRoundingMode("HALF_UP");
|
||||
value.setCurrency("CNY");
|
||||
value.setEffectiveFrom(LocalDateTime.now());
|
||||
value.setEffectiveTo(null);
|
||||
value.setStatus("ACTIVE");
|
||||
value.setRemark(DEFAULT_REMARK);
|
||||
value.setInitializationKey(initializationKey);
|
||||
value.setCreatedBy(operatorId);
|
||||
value.setUpdatedBy(operatorId);
|
||||
repository.insertRule(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
CustomerChargeRuleEntity lockForCompensation(long tenantId, String initializationKey) {
|
||||
return repository.findRuleByInitializationKeyForUpdate(tenantId, initializationKey);
|
||||
}
|
||||
|
||||
boolean isUnchangedDefault(CustomerChargeRuleEntity value) {
|
||||
return value != null
|
||||
&& value.getVersion() != null && value.getVersion() == 0
|
||||
&& DEFAULT_RULE_CODE.equals(value.getRuleCode()) && Integer.valueOf(1).equals(value.getRuleVersion())
|
||||
&& value.getTenantId().equals(value.getCustomerId())
|
||||
&& "FBA".equals(value.getBusinessType()) && "CBM".equals(value.getChargeMode())
|
||||
&& "TAX_INCLUDED".equals(value.getTaxMode())
|
||||
&& DEFAULT_CONVERSION_FACTOR.compareTo(value.getConversionFactor()) == 0
|
||||
&& DEFAULT_COEFFICIENT.compareTo(value.getLengthFactor()) == 0
|
||||
&& DEFAULT_COEFFICIENT.compareTo(value.getWidthFactor()) == 0
|
||||
&& DEFAULT_COEFFICIENT.compareTo(value.getHeightFactor()) == 0
|
||||
&& Boolean.TRUE.equals(value.getDefaultRule()) && Integer.valueOf(2).equals(value.getRoundingScale())
|
||||
&& "HALF_UP".equals(value.getRoundingMode()) && "CNY".equals(value.getCurrency())
|
||||
&& value.getEffectiveTo() == null && "ACTIVE".equals(value.getStatus())
|
||||
&& DEFAULT_REMARK.equals(value.getRemark());
|
||||
}
|
||||
|
||||
void deleteUnchangedDefault(long tenantId, String initializationKey) {
|
||||
if (repository.deleteUnchangedInitializedRule(tenantId, initializationKey) != 1) {
|
||||
throw new BizException(ResultCode.INTERNAL_ERROR, "客户权重系数默认规则补偿删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireCustomerName(String value) {
|
||||
if (value == null || value.isBlank() || value.trim().length() > 128) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "客户名称无效");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
+29
-7
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.RuleResponse;
|
||||
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.RuleUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest;
|
||||
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
|
||||
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
|
||||
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -32,6 +33,8 @@ public class CustomerDimensionConfigService {
|
||||
|
||||
@Resource
|
||||
private CustomerDimensionConfigRepository repository;
|
||||
@Resource
|
||||
private CustomerChargeRuleInitializationService chargeRuleInitialization;
|
||||
|
||||
public GroupResponse get(long tenantId) {
|
||||
return response(tenantId, requireCompleteRules(repository.findAll(tenantId)));
|
||||
@@ -78,18 +81,26 @@ public class CustomerDimensionConfigService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InitializeResponse initializeDefault(long tenantId, long operatorId, String initializationKey) {
|
||||
public InitializeResponse initializeDefault(long tenantId, long operatorId, String initializationKey,
|
||||
String customerName) {
|
||||
requireTenant(tenantId);
|
||||
String normalizedKey = requireInitializationKey(initializationKey);
|
||||
List<CustomerDimensionConfigEntity> existing = repository.findAll(tenantId);
|
||||
if (!existing.isEmpty()) {
|
||||
List<CustomerDimensionConfigEntity> complete = requireCompleteRules(existing);
|
||||
return initializationResponse(complete, normalizedKey);
|
||||
boolean ownedByKey = ownedByInitialization(complete, normalizedKey);
|
||||
CustomerChargeRuleEntity chargeRule = ownedByKey
|
||||
? chargeRuleInitialization.initializeDefault(
|
||||
tenantId, operatorId, normalizedKey, customerName)
|
||||
: null;
|
||||
return initializationResponse(complete, chargeRule, ownedByKey);
|
||||
}
|
||||
|
||||
repository.insertDefaults(defaultRows(tenantId, normalizedKey), operatorId);
|
||||
List<CustomerDimensionConfigEntity> persisted = requireCompleteRules(repository.findAll(tenantId));
|
||||
return initializationResponse(persisted, normalizedKey);
|
||||
CustomerChargeRuleEntity chargeRule = chargeRuleInitialization.initializeDefault(
|
||||
tenantId, operatorId, normalizedKey, customerName);
|
||||
return initializationResponse(persisted, chargeRule, true);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -99,6 +110,12 @@ public class CustomerDimensionConfigService {
|
||||
List<CustomerDimensionConfigEntity> locked =
|
||||
repository.findDefaultsForCompensation(tenantId, normalizedKey);
|
||||
if (!isUnchangedDefaultGroup(locked, normalizedKey)) return;
|
||||
CustomerChargeRuleEntity chargeRule = chargeRuleInitialization.lockForCompensation(
|
||||
tenantId, normalizedKey);
|
||||
if (chargeRule != null && !chargeRuleInitialization.isUnchangedDefault(chargeRule)) return;
|
||||
if (chargeRule != null) {
|
||||
chargeRuleInitialization.deleteUnchangedDefault(tenantId, normalizedKey);
|
||||
}
|
||||
if (repository.deleteUnchangedDefaults(tenantId, normalizedKey) != CustomerDimensionBoxType.values().length) {
|
||||
throw new BizException(ResultCode.INTERNAL_ERROR, "客户箱规默认配置补偿删除失败");
|
||||
}
|
||||
@@ -206,11 +223,16 @@ public class CustomerDimensionConfigService {
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean ownedByInitialization(List<CustomerDimensionConfigEntity> values,
|
||||
String initializationKey) {
|
||||
return values.stream().allMatch(value -> initializationKey.equals(value.getInitializationKey()));
|
||||
}
|
||||
|
||||
private static InitializeResponse initializationResponse(List<CustomerDimensionConfigEntity> values,
|
||||
String initializationKey) {
|
||||
boolean ownedByKey = values.stream()
|
||||
.allMatch(value -> initializationKey.equals(value.getInitializationKey()));
|
||||
return new InitializeResponse(values.get(0).getId(), ownedByKey);
|
||||
CustomerChargeRuleEntity chargeRule,
|
||||
boolean ownedByKey) {
|
||||
return new InitializeResponse(values.get(0).getId(),
|
||||
chargeRule == null ? null : chargeRule.getId(), ownedByKey);
|
||||
}
|
||||
|
||||
private static GroupResponse response(long tenantId, List<CustomerDimensionConfigEntity> values) {
|
||||
|
||||
@@ -44,7 +44,6 @@ public class FbaPricingService {
|
||||
private static final BigDecimal ADJUST_TOLERANCE_PERCENT = new BigDecimal("10.00");
|
||||
private static final BigDecimal ONE_HUNDRED = new BigDecimal("100");
|
||||
private static final long AUTOMATIC_CONFIRM_OPERATOR_ID = 0L;
|
||||
private static final String AUTOMATIC_CONFIRM_OPERATOR_NAME = "装柜完成自动确认";
|
||||
private static final String CONTAINER_RECALCULATE_OPERATION = "CONTAINER_FEE_RECALCULATE";
|
||||
@Resource
|
||||
private FbaPricingRepository repository;
|
||||
@@ -99,8 +98,8 @@ public class FbaPricingService {
|
||||
if (published == null || !"SENT".equals(published.getFeeStatus())) {
|
||||
throw conflict("费用状态已变化,请刷新后重试");
|
||||
}
|
||||
if (published.getCustomerConfirmedAt() != null) {
|
||||
throw conflict("客户已确认该费用,不允许再修改或重新发送");
|
||||
if (feeRevisionLocked(inbound, published)) {
|
||||
throw conflict("客户已确认且该票已装柜完成,不允许再修改或重新发送费用");
|
||||
}
|
||||
}
|
||||
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
|
||||
@@ -167,7 +166,7 @@ public class FbaPricingService {
|
||||
repository.insertItem(baseItem(tenantId, fee, rate, chargeable, subtotal, now));
|
||||
if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, fee, additional, now));
|
||||
if (repository.markInboundCalculated(tenantId, inboundId, inbound.getProcessStatus(),
|
||||
estimate.chargeUnit(), chargeable) != 1) {
|
||||
estimate.chargeUnit(), weight, volume, chargeable) != 1) {
|
||||
throw conflict("入库单状态已变化,费用试算未保存");
|
||||
}
|
||||
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
|
||||
@@ -343,7 +342,8 @@ public class FbaPricingService {
|
||||
recalculated.getSubtotalAmount(), now));
|
||||
if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, recalculated, additional, now));
|
||||
if (repository.markInboundContainerFeeRecalculated(tenantId, member.inbound().getId(),
|
||||
member.inbound().getProcessStatus(), estimate.chargeUnit(), member.chargeableQuantity()) != 1) {
|
||||
member.inbound().getProcessStatus(), member.current().getActualWeightKg(),
|
||||
member.current().getActualVolumeCbm(), estimate.chargeUnit(), member.chargeableQuantity()) != 1) {
|
||||
throw conflict("柜内入库单状态已变化,不能重算阶梯费用");
|
||||
}
|
||||
feeDetails.materializeCalculatedFreight(tenantId, member.inbound().getId(), recalculated.getId(),
|
||||
@@ -390,8 +390,8 @@ public class FbaPricingService {
|
||||
public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId,
|
||||
String idempotencyKey) {
|
||||
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
|
||||
if (!feeEditableBeforeLoading(inbound.getProcessStatus())) {
|
||||
throw conflict("订单已进入装柜流程,不能再修改或发送费用");
|
||||
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
|
||||
throw conflict("当前入库单状态不允许确认费用");
|
||||
}
|
||||
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
|
||||
if (current == null) throw conflict("入库单尚无可确认的费用试算");
|
||||
@@ -412,8 +412,8 @@ public class FbaPricingService {
|
||||
public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId,
|
||||
String idempotencyKey) {
|
||||
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
|
||||
if (!feeEditableBeforeLoading(inbound.getProcessStatus())) {
|
||||
throw conflict("订单已进入装柜流程,不能再修改或发送费用");
|
||||
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
|
||||
throw conflict("当前入库单状态不允许发送费用");
|
||||
}
|
||||
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
|
||||
if (current == null) throw conflict("入库单尚无费用快照");
|
||||
@@ -445,21 +445,6 @@ public class FbaPricingService {
|
||||
return response(tenantId, fee.getId(), null);
|
||||
}
|
||||
|
||||
/** 客户未操作时,装柜完成以系统身份确认最新一版已发送费用。 */
|
||||
@Transactional
|
||||
public void autoConfirmAfterLoading(long tenantId, long inboundId) {
|
||||
InboundFeeEntity current = repository.findLatestFeeForUpdate(tenantId, inboundId);
|
||||
if (current == null || !"SENT".equals(current.getFeeStatus())
|
||||
|| current.getCustomerConfirmedAt() != null) {
|
||||
return;
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now(clock);
|
||||
if (repository.confirmFeeByCustomer(tenantId, current.getId(), current.getFeeVersion(),
|
||||
AUTOMATIC_CONFIRM_OPERATOR_ID, AUTOMATIC_CONFIRM_OPERATOR_NAME, now) != 1) {
|
||||
throw conflict("装柜完成自动确认费用失败,请重试");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FeeSnapshotResponse confirmByCustomer(long tenantId, long operatorId, String operatorName,
|
||||
long inboundId, int expectedFeeVersion,
|
||||
@@ -498,15 +483,15 @@ public class FbaPricingService {
|
||||
AdjustFeeRequest request, String idempotencyKey) {
|
||||
InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId);
|
||||
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
||||
if (!isCompletedInbound(inbound.getProcessStatus())) {
|
||||
throw conflict("只能在待分柜阶段修改初算或已发布费用");
|
||||
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
|
||||
throw conflict("当前入库单状态不允许修改初算或已发布费用");
|
||||
}
|
||||
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
|
||||
if (current == null || !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) {
|
||||
throw conflict("入库单没有可修改的初算或已发布费用");
|
||||
}
|
||||
if (current.getCustomerConfirmedAt() != null) {
|
||||
throw conflict("客户已确认该费用,不允许再修改");
|
||||
if (feeRevisionLocked(inbound, current)) {
|
||||
throw conflict("客户已确认且该票已装柜完成,不允许再修改费用");
|
||||
}
|
||||
if (!request.expectedFeeVersion().equals(current.getFeeVersion())) {
|
||||
throw conflict("费用版本已变化,请刷新后重试");
|
||||
@@ -554,6 +539,9 @@ public class FbaPricingService {
|
||||
adjusted.setConfirmedAt(published ? now : null);
|
||||
adjusted.setSentBy(published ? operatorId : null);
|
||||
adjusted.setSentAt(published ? now : null);
|
||||
adjusted.setCustomerConfirmedBy(null);
|
||||
adjusted.setCustomerConfirmedByName(null);
|
||||
adjusted.setCustomerConfirmedAt(null);
|
||||
adjusted.setCreatedAt(now);
|
||||
adjusted.setUpdatedAt(now);
|
||||
if (repository.supersedeCurrent(tenantId, current.getId(), current.getFeeStatus()) != 1) {
|
||||
@@ -576,8 +564,8 @@ public class FbaPricingService {
|
||||
extra.setRemark(request.reason().trim());
|
||||
repository.insertItem(extra);
|
||||
}
|
||||
if (repository.markInboundAdjusted(tenantId, inboundId, adjusted.getFeeStatus(),
|
||||
adjusted.getChargeUnit(), quantity) != 1) {
|
||||
if (repository.markInboundAdjusted(tenantId, inboundId, inbound.getProcessStatus(), adjusted.getFeeStatus(),
|
||||
adjusted.getActualWeightKg(), adjusted.getActualVolumeCbm(), adjusted.getChargeUnit(), quantity) != 1) {
|
||||
throw conflict("入库单状态已变化,费用调整未保存");
|
||||
}
|
||||
if (published) {
|
||||
@@ -594,7 +582,7 @@ public class FbaPricingService {
|
||||
|
||||
private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) {
|
||||
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
|
||||
if (!feeEditableBeforeLoading(inbound.getProcessStatus())
|
||||
if (!feeRevisionStatusSupported(inbound.getProcessStatus())
|
||||
|| !List.of("UNPRICED", "CALCULATED", "SENT").contains(inbound.getFeeStatus())) {
|
||||
throw conflict("当前入库单状态不允许费用试算");
|
||||
}
|
||||
@@ -613,20 +601,23 @@ public class FbaPricingService {
|
||||
return inbound;
|
||||
}
|
||||
|
||||
private static boolean isCompletedInbound(String processStatus) {
|
||||
return "WAIT_SPLIT".equals(processStatus);
|
||||
}
|
||||
|
||||
private static boolean preserveProcessStatusDuringPricing(String processStatus) {
|
||||
return List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING").contains(processStatus);
|
||||
return List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")
|
||||
.contains(processStatus);
|
||||
}
|
||||
|
||||
private static boolean feeEditableBeforeLoading(String processStatus) {
|
||||
private static boolean feeRevisionStatusSupported(String processStatus) {
|
||||
return List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
|
||||
"WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING")
|
||||
"WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING",
|
||||
"LOADED", "DELIVERED")
|
||||
.contains(processStatus);
|
||||
}
|
||||
|
||||
private static boolean feeRevisionLocked(InboundPricingEntity inbound, InboundFeeEntity fee) {
|
||||
return fee != null && fee.getCustomerConfirmedAt() != null
|
||||
&& List.of("LOADED", "DELIVERED").contains(inbound.getProcessStatus());
|
||||
}
|
||||
|
||||
private static PricingDestinationType destinationType(InboundPricingEntity inbound) {
|
||||
try {
|
||||
return PricingDestinationType.valueOf(inbound.getDestinationType());
|
||||
|
||||
+21
@@ -92,6 +92,27 @@ public class InboundCartonLabelPrintService {
|
||||
return ResponseEntity.status(response.getStatusCode()).headers(headers).body(response.getBody());
|
||||
}
|
||||
|
||||
public ResponseEntity<byte[]> generateForPlatformInbounds(
|
||||
List<InboundCartonLabels.PlatformBatchItem> items, String requestId) {
|
||||
if (items == null || items.isEmpty() || items.size() > 200
|
||||
|| items.stream().anyMatch(item -> item == null || item.tenantId() <= 0 || item.inboundId() <= 0)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "批量打印必须选择1至200个入库单");
|
||||
}
|
||||
List<InboundCartonLabels.PlatformBatchItem> distinctItems = items.stream().distinct().toList();
|
||||
List<InboundCartonLabels.Label> labels = new ArrayList<>();
|
||||
for (InboundCartonLabels.PlatformBatchItem item : distinctItems) {
|
||||
labels.addAll(labelService.list(item.tenantId(), item.inboundId(), null).labels());
|
||||
}
|
||||
if (labels.isEmpty()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "所选订单没有可生成的箱唛");
|
||||
}
|
||||
ResponseEntity<byte[]> response = generatePdf("carton-labels-" + labels.size() + ".pdf", labels, requestId);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.putAll(response.getHeaders());
|
||||
headers.set("X-Carton-Label-Order-Count", String.valueOf(distinctItems.size()));
|
||||
return ResponseEntity.status(response.getStatusCode()).headers(headers).body(response.getBody());
|
||||
}
|
||||
|
||||
/** 一件代发托盘标签:托盘号、入仓码、SKU箱数和托盘二维码。 */
|
||||
public ResponseEntity<byte[]> generateDropshipPallet(Pallet pallet, String requestId) {
|
||||
if (pallet == null || !StringUtils.hasText(pallet.palletNo()) || pallet.items().isEmpty()) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.repository.InboundCartonLabelRepository;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -117,7 +118,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
||||
throw new BizException(ResultCode.INTERNAL_ERROR, "预入库单补建后未能读取");
|
||||
}
|
||||
return prepared;
|
||||
} catch (FeignException exception) {
|
||||
} catch (FeignException | NoFallbackAvailableException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法补建预入库单");
|
||||
}
|
||||
}
|
||||
@@ -181,7 +182,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
||||
.thenComparing(OrderCargoClient.CargoLabelBinding::cargoId,
|
||||
java.util.Comparator.nullsLast(Long::compareTo)))
|
||||
.toList();
|
||||
} catch (FeignException exception) {
|
||||
} catch (FeignException | NoFallbackAvailableException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法生成唛头");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
|
||||
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
|
||||
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
|
||||
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
|
||||
import com.baiyue.firstleg.repository.FbaPricingRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -61,6 +62,8 @@ public class InboundMeasurementService {
|
||||
@Resource
|
||||
private MeasurementBatchService batchService;
|
||||
@Resource
|
||||
private FbaPricingRepository pricingRepository;
|
||||
@Resource
|
||||
private FbaMasterDataService fbaMasterDataService;
|
||||
@Resource
|
||||
private OrderCargoClient orderCargoClient;
|
||||
@@ -162,6 +165,68 @@ public class InboundMeasurementService {
|
||||
return new InboundMeasurementResponses.DraftSaved(saved.getId(), saved.getVersion(), saved.getStatus());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InboundMeasurementResponses.CalculationStrategyUpdated updateCalculationStrategy(
|
||||
long tenantId, long inboundId,
|
||||
InboundMeasurementRequests.UpdateCalculationStrategyRequest request) {
|
||||
InboundOrderEntity inbound = repository.lockInbound(tenantId, inboundId);
|
||||
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
||||
requireMeasurementRevisionEditable(tenantId, inboundId, inbound, "箱规计算规则");
|
||||
if ("MULTIPLY".equals(request.coefficientMode()) && request.chargeRuleId() == null) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "箱规系数模式必须选择计费规则");
|
||||
}
|
||||
InboundMeasurementEntity measurement = repository.lockMeasurement(
|
||||
tenantId, inboundId, request.measurementId());
|
||||
if (measurement == null) throw new BizException(ResultCode.NOT_FOUND, "测量批次不存在");
|
||||
requireVersion(request.version(), measurement.getVersion());
|
||||
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
|
||||
batchService.recalculateStrategy(tenantId, measurement, items,
|
||||
request.coefficientMode(), request.chargeRuleId());
|
||||
measurement.setUpdatedAt(LocalDateTime.now());
|
||||
if (!repository.updateMeasurement(measurement)) throw concurrentModification();
|
||||
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
|
||||
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
|
||||
if (!repository.updateInbound(inbound)) throw concurrentModification();
|
||||
Long appliedRuleId = "MULTIPLY".equals(request.coefficientMode()) ? request.chargeRuleId() : null;
|
||||
return new InboundMeasurementResponses.CalculationStrategyUpdated(
|
||||
measurement.getId(), measurement.getVersion(), measurement.getStatus(),
|
||||
request.coefficientMode(), appliedRuleId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InboundMeasurementResponses.OuterDimensionsUpdated updateOuterDimensions(
|
||||
long tenantId, long inboundId, long itemId,
|
||||
InboundMeasurementRequests.UpdateOuterDimensionsRequest request) {
|
||||
InboundOrderEntity inbound = repository.lockInbound(tenantId, inboundId);
|
||||
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
||||
requireMeasurementRevisionEditable(tenantId, inboundId, inbound, "仓库外箱尺寸");
|
||||
InboundMeasurementEntity measurement = repository.lockMeasurement(
|
||||
tenantId, inboundId, request.measurementId());
|
||||
if (measurement == null) throw new BizException(ResultCode.NOT_FOUND, "测量批次不存在");
|
||||
requireVersion(request.version(), measurement.getVersion());
|
||||
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
|
||||
batchService.recalculateOuterDimensions(tenantId, measurement, items, itemId,
|
||||
request.outerLengthCm(), request.outerWidthCm(), request.outerHeightCm());
|
||||
measurement.setUpdatedAt(LocalDateTime.now());
|
||||
if (!repository.updateMeasurement(measurement)) throw concurrentModification();
|
||||
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
|
||||
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
|
||||
if (!repository.updateInbound(inbound)) throw concurrentModification();
|
||||
return new InboundMeasurementResponses.OuterDimensionsUpdated(
|
||||
measurement.getId(), measurement.getVersion(), measurement.getStatus(), itemId);
|
||||
}
|
||||
|
||||
private void requireMeasurementRevisionEditable(long tenantId, long inboundId,
|
||||
InboundOrderEntity inbound, String subject) {
|
||||
var currentFee = pricingRepository.findLatestFeeForUpdate(tenantId, inboundId);
|
||||
boolean customerConfirmed = currentFee != null && currentFee.getCustomerConfirmedAt() != null;
|
||||
boolean loadingCompleted = Set.of("LOADED", "DELIVERED").contains(inbound.getProcessStatus());
|
||||
if (customerConfirmed && loadingCompleted) {
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"客户已确认且该票已装柜完成,不能修改" + subject);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InboundMeasurementResponses.MeasurementSubmitted submit(
|
||||
long tenantId, long inboundId, long operatorId,
|
||||
@@ -180,6 +245,10 @@ public class InboundMeasurementService {
|
||||
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
|
||||
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
|
||||
ConfirmedWeightTotals confirmedTotals = confirmedWeightTotals(weights);
|
||||
if (confirmedTotals.palletCount() == 0) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"请至少上传并确认一张称重图片后再提交测量审核");
|
||||
}
|
||||
|
||||
requireConfirmedPackageTotal(inbound, items);
|
||||
measurement.setStatus(STATUS_SUBMITTED);
|
||||
|
||||
@@ -246,7 +246,7 @@ public class LoadingPickService {
|
||||
|
||||
public InventorySnapshot inventorySnapshot(long taskId, long warehouseId) {
|
||||
LoadingPickTaskEntity task = requiredTask(taskId);
|
||||
List<LoadingPickItemEntity> items = warehouseScopedItems(task, warehouseId);
|
||||
List<LoadingPickItemEntity> items = taskItems(task, warehouseId);
|
||||
return buildInventorySnapshot(task, warehouseId, items);
|
||||
}
|
||||
|
||||
@@ -326,10 +326,7 @@ public class LoadingPickService {
|
||||
assertReplay(replay, taskId, itemId, requestHash);
|
||||
return view(task, request.warehouseId());
|
||||
}
|
||||
LoadingPickItemEntity item = repository.item(taskId, itemId, request.warehouseId());
|
||||
if (item == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在");
|
||||
}
|
||||
LoadingPickItemEntity item = requireTaskItem(taskId, itemId, request.warehouseId());
|
||||
requirePackageUnit(item);
|
||||
if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) {
|
||||
throw conflict("拣货数量超过计划箱数或明细版本已变化");
|
||||
@@ -404,7 +401,7 @@ public class LoadingPickService {
|
||||
assertReplay(replay, taskId, itemId, requestHash);
|
||||
return view(task, request.warehouseId());
|
||||
}
|
||||
LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId());
|
||||
LoadingPickItemEntity item = requireTaskItem(taskId, itemId, request.warehouseId());
|
||||
requirePackageUnit(item);
|
||||
if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(),
|
||||
request.warehouseId()) != 1) {
|
||||
@@ -437,7 +434,7 @@ public class LoadingPickService {
|
||||
assertReplay(replay, taskId, itemId, requestHash);
|
||||
return view(task, request.warehouseId());
|
||||
}
|
||||
LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId());
|
||||
LoadingPickItemEntity item = requireTaskItem(taskId, itemId, request.warehouseId());
|
||||
requirePackageUnit(item);
|
||||
if (repository.hasPickedOrPartialItem(taskId, item.getInboundId())) {
|
||||
throw conflict("同一入库单已存在已拣或部分拣货明细,不能跳拣并恢复智能装柜候选");
|
||||
@@ -940,10 +937,10 @@ public class LoadingPickService {
|
||||
return task;
|
||||
}
|
||||
|
||||
private LoadingPickItemEntity requireWarehouseItem(long taskId, long itemId, long warehouseId) {
|
||||
private LoadingPickItemEntity requireTaskItem(long taskId, long itemId, long warehouseId) {
|
||||
LoadingPickItemEntity item = repository.item(taskId, itemId, warehouseId);
|
||||
if (item == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在或不属于当前仓库");
|
||||
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在或不属于当前任务");
|
||||
}
|
||||
return item;
|
||||
}
|
||||
@@ -953,16 +950,12 @@ public class LoadingPickService {
|
||||
if (booking == null || !Objects.equals(task.getContainerBookingId(), booking.getId())) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或柜次已非有效存续记录");
|
||||
}
|
||||
if (warehouseId != null && !Objects.equals(warehouseId, booking.getWarehouseId())) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或不属于当前仓库");
|
||||
}
|
||||
List<LoadingPickItemEntity> source = warehouseId == null
|
||||
? repository.items(task.getId()) : repository.items(task.getId(), warehouseId);
|
||||
Map<Long, List<CartonLabelRow>> cartonsByItem = repository.cartonLabels(task.getId()).stream()
|
||||
.collect(Collectors.groupingBy(CartonLabelRow::getPickItemId));
|
||||
Map<Long, Long> tenantByInbound = tenantByInbound(task.getId());
|
||||
Map<Long, List<InventoryResource>> inventoryByInbound = inventoryByInbound(
|
||||
booking.getWarehouseId(), source, tenantByInbound);
|
||||
Map<Long, List<InventoryResource>> inventoryByInbound = inventoryByInbound(source, tenantByInbound);
|
||||
List<PickItem> items = source.stream().map(item -> {
|
||||
List<CartonLabelRow> cartons = cartonsByItem.getOrDefault(item.getId(), List.of());
|
||||
CartonLabelRow first = cartons.isEmpty() ? null : cartons.get(0);
|
||||
@@ -1011,11 +1004,11 @@ public class LoadingPickService {
|
||||
private List<BatchLine> validateBatchLines(LoadingPickTaskEntity task, BatchRequest request) {
|
||||
long taskId = task.getId();
|
||||
List<LoadingPickItemEntity> allItems = repository.items(taskId);
|
||||
List<LoadingPickItemEntity> scopedItems = repository.items(taskId, request.warehouseId());
|
||||
if (allItems.isEmpty() || scopedItems.size() != allItems.size()) {
|
||||
throw conflict("PDA任务柜次、仓库或客户归属不一致");
|
||||
List<LoadingPickItemEntity> taskItems = repository.items(taskId, request.warehouseId());
|
||||
if (allItems.isEmpty() || taskItems.size() != allItems.size()) {
|
||||
throw conflict("PDA任务柜次或客户归属不一致");
|
||||
}
|
||||
Map<Long, LoadingPickItemEntity> itemsById = scopedItems.stream().collect(Collectors.toMap(
|
||||
Map<Long, LoadingPickItemEntity> itemsById = taskItems.stream().collect(Collectors.toMap(
|
||||
LoadingPickItemEntity::getId, item -> item));
|
||||
Set<Long> unresolvedIds = allItems.stream().filter(item -> !isResolved(item))
|
||||
.map(LoadingPickItemEntity::getId).collect(Collectors.toSet());
|
||||
@@ -1033,7 +1026,7 @@ public class LoadingPickService {
|
||||
LoadingPickItemEntity item = itemsById.get(requestItem.itemId());
|
||||
if (item == null || item.getInboundId() == null || item.getLoadingPlanItemId() == null
|
||||
|| item.getInboundDetailId() == null) {
|
||||
throw conflict("PDA明细不属于当前任务、柜次、仓库或客户");
|
||||
throw conflict("PDA明细不属于当前任务、柜次或客户");
|
||||
}
|
||||
if (!"SKIP".equals(requestItem.action())) {
|
||||
inboundHasWork.put(item.getInboundId(), true);
|
||||
@@ -1096,7 +1089,7 @@ public class LoadingPickService {
|
||||
}
|
||||
result.add(new BatchLine(item, requestItem, action, request.warehouseId()));
|
||||
}
|
||||
validateWholePallets(result, scopedItems, request.warehouseId());
|
||||
validateWholePallets(result, taskItems, request.warehouseId());
|
||||
result.sort(Comparator.comparing(line -> line.item().getId()));
|
||||
return result;
|
||||
}
|
||||
@@ -1121,19 +1114,19 @@ public class LoadingPickService {
|
||||
}
|
||||
}
|
||||
|
||||
private List<LoadingPickItemEntity> warehouseScopedItems(LoadingPickTaskEntity task, long warehouseId) {
|
||||
private List<LoadingPickItemEntity> taskItems(LoadingPickTaskEntity task, long warehouseId) {
|
||||
List<LoadingPickItemEntity> allItems = repository.items(task.getId());
|
||||
List<LoadingPickItemEntity> scopedItems = repository.items(task.getId(), warehouseId);
|
||||
if (allItems.isEmpty() || scopedItems.size() != allItems.size()) {
|
||||
throw conflict("PDA任务柜次、仓库或客户归属不一致");
|
||||
List<LoadingPickItemEntity> taskItems = repository.items(task.getId(), warehouseId);
|
||||
if (allItems.isEmpty() || taskItems.size() != allItems.size()) {
|
||||
throw conflict("PDA任务柜次或客户归属不一致");
|
||||
}
|
||||
return scopedItems;
|
||||
return taskItems;
|
||||
}
|
||||
|
||||
private InventorySnapshot buildInventorySnapshot(LoadingPickTaskEntity task, long warehouseId,
|
||||
List<LoadingPickItemEntity> items) {
|
||||
Map<Long, List<InventoryResource>> inventoryByInbound = inventoryByInbound(
|
||||
warehouseId, items, tenantByInbound(task.getId()));
|
||||
items, tenantByInbound(task.getId()));
|
||||
List<InboundInventory> inbounds = items.stream().map(LoadingPickItemEntity::getInboundId)
|
||||
.filter(Objects::nonNull).distinct().sorted().map(inboundId -> {
|
||||
List<InventoryResource> resources = inventoryByInbound.getOrDefault(inboundId, List.of());
|
||||
@@ -1160,8 +1153,7 @@ public class LoadingPickService {
|
||||
}));
|
||||
}
|
||||
|
||||
private Map<Long, List<InventoryResource>> inventoryByInbound(long warehouseId,
|
||||
List<LoadingPickItemEntity> items,
|
||||
private Map<Long, List<InventoryResource>> inventoryByInbound(List<LoadingPickItemEntity> items,
|
||||
Map<Long, Long> tenantByInbound) {
|
||||
Map<Long, List<InventoryResource>> result = new HashMap<>();
|
||||
for (Long inboundId : items.stream().map(LoadingPickItemEntity::getInboundId)
|
||||
@@ -1169,7 +1161,6 @@ public class LoadingPickService {
|
||||
Long tenantId = tenantByInbound.get(inboundId);
|
||||
if (tenantId == null) throw conflict("PDA任务缺少入库单客户归属");
|
||||
List<InventoryResource> resources = palletAllocations.list(tenantId, inboundId).stream()
|
||||
.filter(resource -> Objects.equals(resource.getWarehouseId(), warehouseId))
|
||||
.filter(resource -> "CONFIRMED".equals(resource.getAllocationStatus()))
|
||||
.sorted(Comparator.comparing(InboundPalletAllocationEntity::getBlockId,
|
||||
Comparator.nullsLast(Long::compareTo))
|
||||
@@ -1222,7 +1213,7 @@ public class LoadingPickService {
|
||||
|
||||
private void validateInventorySnapshot(LoadingPickTaskEntity task, BatchRequest request,
|
||||
List<BatchLine> lines) {
|
||||
List<LoadingPickItemEntity> allItems = warehouseScopedItems(task, request.warehouseId());
|
||||
List<LoadingPickItemEntity> allItems = taskItems(task, request.warehouseId());
|
||||
InventorySnapshot current = buildInventorySnapshot(task, request.warehouseId(), allItems);
|
||||
if (!current.snapshotToken().equals(request.inventorySnapshotToken().trim())) {
|
||||
throw conflict("库存已变化,请刷新后重试");
|
||||
|
||||
@@ -93,6 +93,87 @@ public class MeasurementBatchService {
|
||||
return measurement;
|
||||
}
|
||||
|
||||
void recalculateStrategy(long tenantId, InboundMeasurementEntity measurement,
|
||||
List<InboundMeasurementItemEntity> existingItems,
|
||||
String coefficientMode, Long chargeRuleId) {
|
||||
if (existingItems.isEmpty()) {
|
||||
throw new BizException(ResultCode.CONFLICT, "当前测量批次没有可重算的明细");
|
||||
}
|
||||
MeasurementBatchCommand command = new MeasurementBatchCommand(
|
||||
tenantId, measurement.getInboundId(), measurement.getId(), measurement.getVersion(),
|
||||
measurement.getSourceType(), measurement.getSourceCode(), measurement.getExternalEventNo(),
|
||||
measurement.getDeviceCode(), measurement.getWarehouseCode(), measurement.getMeasuredAt(),
|
||||
measurement.getRawPayloadJson(), measurement.getOriginalDocumentNo(),
|
||||
measurement.getOriginalGrossWeightKg(), measurement.getOriginalVolumeCbm(),
|
||||
measurement.getActualGoodsName(), measurement.getCreatedBy(),
|
||||
existingItems.stream().map(item -> new MeasurementBatchCommand.Item(
|
||||
item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
|
||||
item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), null, item.getSourceImageFileId(),
|
||||
item.getOverLimitWarning(), item.getManualOuterLengthCm(), item.getManualOuterWidthCm(),
|
||||
item.getManualOuterHeightCm(), chargeRuleId, coefficientMode, item.getRemark())).toList());
|
||||
List<InboundMeasurementItemEntity> recalculated = calculateItems(
|
||||
command, dimensionWeighting.load(tenantId));
|
||||
for (int index = 0; index < existingItems.size(); index++) {
|
||||
InboundMeasurementItemEntity target = existingItems.get(index);
|
||||
InboundMeasurementItemEntity source = recalculated.get(index);
|
||||
applyRecalculatedItem(target, source, false);
|
||||
}
|
||||
applyTotals(measurement, existingItems);
|
||||
}
|
||||
|
||||
void recalculateOuterDimensions(long tenantId, InboundMeasurementEntity measurement,
|
||||
List<InboundMeasurementItemEntity> existingItems, long itemId,
|
||||
BigDecimal outerLengthCm, BigDecimal outerWidthCm,
|
||||
BigDecimal outerHeightCm) {
|
||||
InboundMeasurementItemEntity target = existingItems.stream()
|
||||
.filter(item -> item.getId() != null && item.getId() == itemId)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "测量明细不存在"));
|
||||
MeasurementBatchCommand command = new MeasurementBatchCommand(
|
||||
tenantId, measurement.getInboundId(), measurement.getId(), measurement.getVersion(),
|
||||
measurement.getSourceType(), measurement.getSourceCode(), measurement.getExternalEventNo(),
|
||||
measurement.getDeviceCode(), measurement.getWarehouseCode(), measurement.getMeasuredAt(),
|
||||
measurement.getRawPayloadJson(), measurement.getOriginalDocumentNo(),
|
||||
measurement.getOriginalGrossWeightKg(), measurement.getOriginalVolumeCbm(),
|
||||
measurement.getActualGoodsName(), measurement.getCreatedBy(),
|
||||
List.of(new MeasurementBatchCommand.Item(
|
||||
target.getInboundDetailId(), target.getItemNo(), target.getBoxSpecification(),
|
||||
target.getPackageCount(), target.getLengthCm(), target.getWidthCm(), target.getHeightCm(),
|
||||
null, target.getSourceImageFileId(), target.getOverLimitWarning(), outerLengthCm,
|
||||
outerWidthCm, outerHeightCm, target.getChargeRuleId(), target.getCoefficientMode(),
|
||||
target.getRemark())));
|
||||
InboundMeasurementItemEntity recalculated = calculateItems(
|
||||
command, dimensionWeighting.load(tenantId)).get(0);
|
||||
applyRecalculatedItem(target, recalculated, true);
|
||||
applyTotals(measurement, existingItems);
|
||||
}
|
||||
|
||||
private void applyRecalculatedItem(InboundMeasurementItemEntity target,
|
||||
InboundMeasurementItemEntity source,
|
||||
boolean updateManualDimensions) {
|
||||
target.setGirthCm(source.getGirthCm());
|
||||
target.setUnitVolumeCbm(source.getUnitVolumeCbm());
|
||||
target.setVolumeRawCbm(source.getVolumeRawCbm());
|
||||
target.setVolumeCbm(source.getVolumeCbm());
|
||||
target.setTotalVolumeCbm(source.getTotalVolumeCbm());
|
||||
target.setVolumeWeightKg(source.getVolumeWeightKg());
|
||||
target.setChargeRuleId(source.getChargeRuleId());
|
||||
target.setCoefficientMode(source.getCoefficientMode());
|
||||
target.setCalculationRuleVersion(source.getCalculationRuleVersion());
|
||||
target.setOuterLengthCm(source.getOuterLengthCm());
|
||||
target.setOuterWidthCm(source.getOuterWidthCm());
|
||||
target.setOuterHeightCm(source.getOuterHeightCm());
|
||||
if (updateManualDimensions) {
|
||||
target.setManualOuterLengthCm(source.getManualOuterLengthCm());
|
||||
target.setManualOuterWidthCm(source.getManualOuterWidthCm());
|
||||
target.setManualOuterHeightCm(source.getManualOuterHeightCm());
|
||||
}
|
||||
target.setUpdatedAt(LocalDateTime.now());
|
||||
if (!repository.updateItem(target)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 人工补录会整批替换可编辑测量项,但不能抹掉同一项已冻结的扫码身份与设备取证。
|
||||
* 只按入库货物明细和明细号精确匹配,避免把一个箱码猜测性地复制给其他测量项。
|
||||
|
||||
+11
-1
@@ -18,7 +18,11 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
|
||||
SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId,
|
||||
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
|
||||
FROM fl_container_booking b
|
||||
WHERE b.id=#{containerBookingId} FOR UPDATE
|
||||
WHERE b.id=#{containerBookingId}
|
||||
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d
|
||||
WHERE d.container_booking_id=b.id AND d.confirmed_at IS NOT NULL
|
||||
AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
|
||||
FOR UPDATE
|
||||
""")
|
||||
Context lockContext(@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@@ -27,6 +31,9 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
|
||||
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
|
||||
FROM fl_container_booking b
|
||||
WHERE b.id=#{containerBookingId}
|
||||
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d
|
||||
WHERE d.container_booking_id=b.id AND d.confirmed_at IS NOT NULL
|
||||
AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
|
||||
""")
|
||||
Context context(@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@@ -37,6 +44,9 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
|
||||
FROM fl_container_booking b
|
||||
WHERE b.id IN
|
||||
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d
|
||||
WHERE d.container_booking_id=b.id AND d.confirmed_at IS NOT NULL
|
||||
AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
|
||||
ORDER BY b.id
|
||||
</script>
|
||||
""")
|
||||
|
||||
+4
-2
@@ -45,8 +45,10 @@ public class ContainerWorkbenchService {
|
||||
public WorkbenchItem overseasDetail(long id, Set<String> permissions) {
|
||||
ContainerBookingEntity value = required(id);
|
||||
if (!repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国外工作台货柜不存在");
|
||||
View drayage = terminalDrayages.authorize(terminalDrayages.detail(id), permissions);
|
||||
return item("OVERSEAS", value, drayage.actualFacts(), drayage, permissions);
|
||||
View drayage = terminalDrayages.authorize(terminalDrayages.views(List.of(id)), permissions).stream()
|
||||
.filter(candidate -> candidate.containerBookingId() == id)
|
||||
.findFirst().orElse(null);
|
||||
return item("OVERSEAS", value, drayage == null ? null : drayage.actualFacts(), drayage, permissions);
|
||||
}
|
||||
|
||||
private PageResult<WorkbenchItem> page(String workbench, Query query, Set<String> permissions) {
|
||||
|
||||
+18
-16
@@ -5,7 +5,7 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest;
|
||||
@@ -123,7 +123,7 @@ public class TerminalDrayageService {
|
||||
requireVersion(value, request.expectedVersion());
|
||||
if (value.getFleetSupplierId() == null) throw conflict("请先选择有效码头车队");
|
||||
if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可修改预约提柜时间");
|
||||
if (context.facts().arrivedDestinationPortAt() != null
|
||||
if (context.facts() != null && context.facts().arrivedDestinationPortAt() != null
|
||||
&& request.pickupAppointmentAt().isBefore(context.facts().arrivedDestinationPortAt())) {
|
||||
throw badRequest("预约提柜时间不能早于实际到港时间");
|
||||
}
|
||||
@@ -149,7 +149,8 @@ public class TerminalDrayageService {
|
||||
throw conflict("选择码头车队并预约提柜后才能确认实际提柜");
|
||||
}
|
||||
if (actualPickupAt(context, value) != null) throw conflict("实际提柜时间已确认");
|
||||
if (context.facts().latestActualAt() != null && request.actualAt().isBefore(context.facts().latestActualAt())) {
|
||||
if (context.facts() != null && context.facts().latestActualAt() != null
|
||||
&& request.actualAt().isBefore(context.facts().latestActualAt())) {
|
||||
throw badRequest("实际提柜时间不能早于当前实际船节点时间");
|
||||
}
|
||||
advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key,
|
||||
@@ -192,14 +193,11 @@ public class TerminalDrayageService {
|
||||
|
||||
private void advanceNode(WorkContext context, CarrierScheduleNode target, LocalDateTime actualAt,
|
||||
String action, String key, String reason, long operatorId, String operatorName) {
|
||||
if (context.facts().queryId() == null || context.facts().queryVersion() == null) {
|
||||
throw conflict("货柜尚未建立船节点跟踪任务");
|
||||
}
|
||||
CarrierScheduleNode current = node(context.facts().latestActualNode());
|
||||
CarrierScheduleNode current = currentNode(context);
|
||||
if (current != null && current.sequence() >= target.sequence()) return;
|
||||
carrierSchedules.addManualNode(context.facts().queryId(), operatorId, operatorName,
|
||||
carrierSchedules.addContainerManualNode(context.base().containerBookingId(), operatorId, operatorName,
|
||||
"td-" + hash(context.base().containerBookingId() + "|" + action + "|" + key.trim()),
|
||||
new ManualNodeRequest(context.facts().queryVersion(), target.name(), LocalDateTime.now(clock), null,
|
||||
new ContainerManualNodeRequest(target.name(), LocalDateTime.now(clock), null,
|
||||
actualAt, null, null, StringUtils.hasText(reason) ? reason.trim() : "码头拖车操作同步"));
|
||||
}
|
||||
|
||||
@@ -292,7 +290,7 @@ public class TerminalDrayageService {
|
||||
List<String> result = new ArrayList<>();
|
||||
if (value != null && value.getFleetSupplierId() != null && actualPickupAt(context, value) == null
|
||||
&& actualEmptyReturnAt(context, value) == null && context.base().unloadingCompletedAt() == null) result.add("SEND_PICKUP_EMAIL");
|
||||
CarrierScheduleNode current = node(context.facts().latestActualNode());
|
||||
CarrierScheduleNode current = currentNode(context);
|
||||
if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
|
||||
&& current.sequence() < CarrierScheduleNode.PICKED_UP.sequence()
|
||||
&& actualPickupAt(context, value) == null) {
|
||||
@@ -312,7 +310,7 @@ public class TerminalDrayageService {
|
||||
if (context.base().unloadingCompletedAt() != null) return "UNLOADED";
|
||||
if (actualPickupAt(context, value) != null) return "PICKED_UP";
|
||||
if (value != null && value.getPickupAppointmentAt() != null) return "PICKUP_SCHEDULED";
|
||||
CarrierScheduleNode current = node(context.facts().latestActualNode());
|
||||
CarrierScheduleNode current = currentNode(context);
|
||||
return current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
|
||||
? "ARRIVED" : "WAITING_FOR_ARRIVAL";
|
||||
}
|
||||
@@ -322,7 +320,7 @@ public class TerminalDrayageService {
|
||||
}
|
||||
|
||||
private void requireAtLeast(WorkContext context, CarrierScheduleNode required, String message) {
|
||||
CarrierScheduleNode current = node(context.facts().latestActualNode());
|
||||
CarrierScheduleNode current = currentNode(context);
|
||||
if (current == null || current.sequence() < required.sequence()) throw conflict(message);
|
||||
}
|
||||
|
||||
@@ -330,13 +328,18 @@ public class TerminalDrayageService {
|
||||
return StringUtils.hasText(value) ? CarrierScheduleNode.require(value) : null;
|
||||
}
|
||||
|
||||
private CarrierScheduleNode currentNode(WorkContext context) {
|
||||
return context.facts() == null ? null : node(context.facts().latestActualNode());
|
||||
}
|
||||
|
||||
private LocalDateTime actualPickupAt(WorkContext context, TerminalDrayageEntity value) {
|
||||
return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt() : context.facts().pickedUpAt();
|
||||
return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt()
|
||||
: context.facts() == null ? null : context.facts().pickedUpAt();
|
||||
}
|
||||
|
||||
private LocalDateTime actualEmptyReturnAt(WorkContext context, TerminalDrayageEntity value) {
|
||||
return value != null && value.getActualEmptyReturnAt() != null
|
||||
? value.getActualEmptyReturnAt() : context.facts().emptyReturnedAt();
|
||||
? value.getActualEmptyReturnAt() : context.facts() == null ? null : context.facts().emptyReturnedAt();
|
||||
}
|
||||
|
||||
private WorkContext requiredContext(long id) {
|
||||
@@ -362,8 +365,7 @@ public class TerminalDrayageService {
|
||||
}
|
||||
|
||||
private WorkContext context(Context base, ContainerActualFacts facts) {
|
||||
if (base == null || !"ACTIVE".equals(base.recordStatus()) || facts == null
|
||||
|| facts.departedWarehouseAt() == null) return null;
|
||||
if (base == null || !"ACTIVE".equals(base.recordStatus())) return null;
|
||||
return new WorkContext(base, facts);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ CALL phase2_fba_add_column('fl_customer_charge_rule', 'length_factor', "DECIMAL(
|
||||
CALL phase2_fba_add_column('fl_customer_charge_rule', 'width_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '宽度计算系数' AFTER length_factor");
|
||||
CALL phase2_fba_add_column('fl_customer_charge_rule', 'height_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '高度计算系数' AFTER width_factor");
|
||||
CALL phase2_fba_add_column('fl_customer_charge_rule', 'default_rule', "TINYINT(1) NOT NULL DEFAULT 0 COMMENT '客户默认生效规则' AFTER status");
|
||||
CALL phase2_fba_add_column('fl_customer_charge_rule', 'initialization_key', "VARCHAR(128) NULL COMMENT '客户创建执行器幂等键及补偿归属' AFTER remark");
|
||||
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', 'measurement_id', "BIGINT UNSIGNED NULL COMMENT '费用绑定的测量批次ID快照' AFTER inbound_id");
|
||||
@@ -67,6 +68,14 @@ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
DROP PROCEDURE IF EXISTS phase2_fba_add_column;
|
||||
|
||||
SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_customer_charge_rule'
|
||||
AND INDEX_NAME = 'idx_charge_rule_initialization');
|
||||
SET @sql = IF(@idx_exists = 0,
|
||||
'ALTER TABLE fl_customer_charge_rule ADD INDEX idx_charge_rule_initialization (tenant_id, initialization_key)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 旧索引可能缺少业务类型、船司和结束时间;用新索引名并存,避免破坏线上依赖。
|
||||
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');
|
||||
|
||||
@@ -431,6 +431,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule (
|
||||
effective_to DATETIME(3) NULL COMMENT '生效结束时间',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态:ACTIVE启用、DISABLED停用',
|
||||
remark VARCHAR(500) NULL COMMENT '规则备注',
|
||||
initialization_key VARCHAR(128) 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',
|
||||
@@ -439,6 +440,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule (
|
||||
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_initialization (tenant_id, initialization_key),
|
||||
KEY idx_charge_rule_match (tenant_id, customer_id, business_type, status, effective_from, effective_to)
|
||||
) ENGINE=InnoDB COMMENT='客户头程计费规则版本';
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
|
||||
CONSTRAINT fk_carrier_schedule_query_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
|
||||
CONSTRAINT fk_carrier_schedule_query_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id),
|
||||
CONSTRAINT fk_carrier_schedule_query_result FOREIGN KEY (result_port_schedule_id) REFERENCES fl_port_schedule(id),
|
||||
CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id > 0),
|
||||
CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id >= 0),
|
||||
CONSTRAINT chk_carrier_schedule_query_carrier CHECK
|
||||
(carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')),
|
||||
CONSTRAINT chk_carrier_schedule_query_tracking CHECK
|
||||
@@ -114,7 +114,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '标准船节点事实ID',
|
||||
query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID',
|
||||
query_id BIGINT UNSIGNED NULL COMMENT '自动跟踪任务ID;人工补录时为空',
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属真实货柜ID',
|
||||
event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键',
|
||||
provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码',
|
||||
provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID',
|
||||
@@ -130,9 +131,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
|
||||
raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_carrier_schedule_node_event (query_id,event_key),
|
||||
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
|
||||
KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id),
|
||||
KEY idx_container_shipping_node_timeline (container_booking_id,observed_at,id),
|
||||
CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
|
||||
CONSTRAINT fk_carrier_schedule_node_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id),
|
||||
CONSTRAINT chk_carrier_schedule_node_standard CHECK
|
||||
(standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT',
|
||||
'IN_TRANSIT','ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')),
|
||||
|
||||
@@ -34,7 +34,7 @@ paths:
|
||||
summary: Query carrier schedule tasks
|
||||
x-permission: platform:firstleg:port-schedule-query:list
|
||||
parameters:
|
||||
- {name: customerId, in: query, schema: {type: integer, format: int64, minimum: 1}}
|
||||
- {name: customerId, in: query, schema: {type: integer, format: int64, minimum: 0}}
|
||||
- {name: oceanBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}}
|
||||
- {name: containerBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}}
|
||||
- {name: carrierCode, in: query, schema: {$ref: '#/components/schemas/TaskCarrierCode'}}
|
||||
@@ -126,7 +126,7 @@ components:
|
||||
required: [expectedVersion, customerId, oceanBookingId, containerBookingId, carrierCode, trackingType, trackingNo]
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0, maximum: 0}
|
||||
customerId: {type: integer, format: int64, minimum: 1}
|
||||
customerId: {type: integer, format: int64, minimum: 0}
|
||||
oceanBookingId: {type: integer, format: int64, minimum: 1}
|
||||
containerBookingId: {type: integer, format: int64, minimum: 1}
|
||||
carrierCode: {$ref: '#/components/schemas/CarrierCode'}
|
||||
|
||||
@@ -123,13 +123,13 @@ components:
|
||||
schemas:
|
||||
LongId: {type: string, pattern: '^[1-9][0-9]*$', description: Long ID序列化为字符串}
|
||||
ApiEnvelope: {type: object, required: [code, message, timestamp], properties: {code: {type: string}, message: {type: string}, requestId: {type: string, nullable: true}, traceId: {type: string, nullable: true}, timestamp: {type: string, format: date-time}}}
|
||||
ContainerEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/ContainerView'}}}]}
|
||||
ContainerEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {allOf: [{$ref: '#/components/schemas/ContainerView'}], nullable: true, description: 装柜计划尚未完成时返回null,表示报关流程尚未生成}}}]}
|
||||
DocumentEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DocumentView'}}}]}
|
||||
ActionRequest: {type: object, required: [expectedVersion, confirm], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, confirm: {type: boolean}, remark: {type: string, maxLength: 500, nullable: true}}}
|
||||
ScopeRequest: {type: object, required: [inboundIds, expectedVersion], additionalProperties: false, properties: {inboundIds: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}, expectedVersion: {type: integer, minimum: 1}}}
|
||||
FileView: {type: object, required: [fileId, fileName, fileHash, sizeBytes, version, uploadedAt], properties: {fileId: {$ref: '#/components/schemas/LongId'}, fileName: {type: string}, fileHash: {type: string}, contentType: {type: string, nullable: true}, sizeBytes: {type: integer, format: int64}, version: {type: integer}, uploadedAt: {type: string, format: date-time}}}
|
||||
InboundView: {type: object, required: [inboundId, orderId, orderNo, inboundCode, tenantId, customerName, taxMode, covered], properties: {inboundId: {$ref: '#/components/schemas/LongId'}, orderId: {$ref: '#/components/schemas/LongId'}, orderNo: {type: string}, inboundCode: {type: string}, tenantId: {$ref: '#/components/schemas/LongId'}, customerName: {type: string}, taxMode: {type: string, enum: [TAX_REFUND, BUY_DECLARATION]}, covered: {type: boolean}}}
|
||||
DocumentView: {type: object, required: [documentId, tenantId, source, inboundIds, inboundCodes, frozen], properties: {documentId: {$ref: '#/components/schemas/LongId'}, tenantId: {$ref: '#/components/schemas/LongId'}, source: {$ref: '#/components/schemas/FileView'}, inboundIds: {type: array, items: {$ref: '#/components/schemas/LongId'}}, inboundCodes: {type: array, items: {type: string}}, frozen: {type: boolean}, completion: {allOf: [{$ref: '#/components/schemas/FileView'}], nullable: true}}}
|
||||
ContainerView: {type: object, required: [aggregateId, loadingPlanId, containerBookingId, status, version, taxRefundCount, coveredTaxRefundCount, inbounds, documents], properties: {aggregateId: {$ref: '#/components/schemas/LongId'}, loadingPlanId: {$ref: '#/components/schemas/LongId'}, containerBookingId: {$ref: '#/components/schemas/LongId'}, planNo: {type: string}, cabinetNo: {type: string}, containerNo: {type: string, nullable: true}, status: {type: string, enum: [WAITING_CUSTOMER_DOCUMENTS, READY_TO_START, DECLARING, COMPLETED]}, merchantStatus: {type: string, enum: [WAITING_UPLOAD, DOCUMENT_UPLOADED, DECLARING, CUSTOMS_COMPLETED], nullable: true}, version: {type: integer}, taxRefundCount: {type: integer}, coveredTaxRefundCount: {type: integer}, inbounds: {type: array, items: {$ref: '#/components/schemas/InboundView'}}, documents: {type: array, items: {$ref: '#/components/schemas/DocumentView'}}, startedAt: {type: string, format: date-time, nullable: true}, completedAt: {type: string, format: date-time, nullable: true}}}
|
||||
ContainerView: {type: object, required: [aggregateId, loadingPlanId, containerBookingId, status, hasActualCargo, version, taxRefundCount, coveredTaxRefundCount, inbounds, documents], properties: {aggregateId: {$ref: '#/components/schemas/LongId'}, loadingPlanId: {$ref: '#/components/schemas/LongId'}, containerBookingId: {$ref: '#/components/schemas/LongId'}, planNo: {type: string}, cabinetNo: {type: string}, containerNo: {type: string, nullable: true}, status: {type: string, enum: [WAITING_CUSTOMER_DOCUMENTS, READY_TO_START, DECLARING, COMPLETED]}, hasActualCargo: {type: boolean, description: 当前完成装柜计划是否仍有实际装柜货物;false时查询成功返回空明细且无需发起报关}, merchantStatus: {type: string, enum: [WAITING_UPLOAD, DOCUMENT_UPLOADED, DECLARING, CUSTOMS_COMPLETED], nullable: true}, version: {type: integer}, taxRefundCount: {type: integer}, coveredTaxRefundCount: {type: integer}, inbounds: {type: array, items: {$ref: '#/components/schemas/InboundView'}}, documents: {type: array, items: {$ref: '#/components/schemas/DocumentView'}}, startedAt: {type: string, format: date-time, nullable: true}, completedAt: {type: string, format: date-time, nullable: true}}}
|
||||
ExportBatchRequest: {type: object, required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit], properties: {taskId: {type: string}, tenantId: {type: string, enum: ['0']}, appCode: {type: string, enum: [PLATFORM]}, requestedBy: {type: string}, dispatchVersion: {type: integer}, parameters: {type: object, required: [loadingPlanId], additionalProperties: false, properties: {loadingPlanId: {type: string}}}, cursor: {type: string, nullable: true}, snapshotToken: {type: string, nullable: true}, limit: {type: integer, minimum: 1, maximum: 500}}}
|
||||
ExportBatchEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {type: object, properties: {schemaVersion: {type: integer}, schemaHash: {type: string}, snapshotToken: {type: string}, columns: {type: array, items: {type: object}}, rows: {type: array, items: {type: object}}, nextCursor: {type: string}, hasMore: {type: boolean}, totalRows: {type: integer, format: int64, nullable: true}}}}}]}
|
||||
|
||||
@@ -923,6 +923,39 @@ paths:
|
||||
requestId: {type: string, nullable: true}
|
||||
traceId: {type: string, nullable: true}
|
||||
'403': {description: 无查看权限}
|
||||
/api/platform/inbounds/carton-label-pdf:
|
||||
post:
|
||||
summary: 批量生成入仓箱唛 PDF
|
||||
description: 需要 platform:firstleg:inbound:view 权限;可跨客户选择1至200个入库单,聚合生成一个 PDF,首次调用冻结各入库单整单箱号。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 200
|
||||
items:
|
||||
type: object
|
||||
required: [tenantId, inboundId]
|
||||
properties:
|
||||
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
responses:
|
||||
'200':
|
||||
description: 成功返回聚合 PDF;业务失败返回统一 JSON 响应,调用方必须按 Content-Type 区分。
|
||||
content:
|
||||
application/pdf: {schema: {type: string, format: binary}}
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string}
|
||||
requestId: {type: string, nullable: true}
|
||||
traceId: {type: string, nullable: true}
|
||||
'403': {description: 无查看权限}
|
||||
/api/platform/v1/first-leg/inbounds/{inboundId}/measurement:
|
||||
get:
|
||||
summary: 查询入库测量详情
|
||||
@@ -964,6 +997,43 @@ paths:
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
'403': {description: 无测量编辑权限}
|
||||
/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/calculation-strategy:
|
||||
put:
|
||||
summary: 更新箱规计算规则
|
||||
description: >-
|
||||
独立更新当前测量批次全部明细的箱规计算规则并重算体积,不覆盖整批测量数据,
|
||||
不改变测量提交或审核状态。客户未确认或该票尚未装柜完成时均可修改;
|
||||
仅当客户已确认费用且该票已装柜完成时冻结。
|
||||
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]*$'}}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/InboundMeasurementCalculationStrategyRequest'}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
'403': {description: 无测量编辑权限}
|
||||
/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/items/{itemId}/outer-dimensions:
|
||||
put:
|
||||
summary: 更新仓库外箱长宽高
|
||||
description: >-
|
||||
独立更新当前测量批次指定明细的仓库外箱尺寸并重算体积,不覆盖整批测量数据,
|
||||
不改变测量提交或审核状态。客户未确认或该票尚未装柜完成时均可修改;
|
||||
仅当客户已确认费用且该票已装柜完成时冻结。
|
||||
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]*$'}}
|
||||
- {name: itemId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/InboundMeasurementOuterDimensionsRequest'}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
'403': {description: 无测量编辑权限}
|
||||
/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans:
|
||||
post:
|
||||
summary: 平台模拟单箱设备扫描
|
||||
@@ -1681,16 +1751,18 @@ components:
|
||||
updatedAt: {type: string, format: date-time, nullable: true}
|
||||
CustomerDimensionConfigInitializeRequest:
|
||||
type: object
|
||||
required: [tenantId, operatorId, initializationKey]
|
||||
required: [tenantId, operatorId, initializationKey, customerName]
|
||||
properties:
|
||||
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
operatorId: {type: string, pattern: '^[0-9]+$'}
|
||||
initializationKey: {type: string, minLength: 1, maxLength: 128}
|
||||
customerName: {type: string, minLength: 1, maxLength: 128}
|
||||
CustomerDimensionConfigInitializeResponse:
|
||||
type: object
|
||||
required: [configId, createdByThisInitialization]
|
||||
required: [configId, chargeRuleId, createdByThisInitialization]
|
||||
properties:
|
||||
configId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
createdByThisInitialization: {type: boolean}
|
||||
WeightPalletSnapshot:
|
||||
type: object
|
||||
@@ -1933,6 +2005,25 @@ components:
|
||||
minItems: 1
|
||||
maxItems: 500
|
||||
items: {$ref: '#/components/schemas/InboundMeasurementItemRequest'}
|
||||
InboundMeasurementCalculationStrategyRequest:
|
||||
type: object
|
||||
required: [measurementId, version, coefficientMode]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
measurementId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
version: {type: integer, minimum: 0}
|
||||
coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY]}
|
||||
chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: MULTIPLY 模式必填}
|
||||
InboundMeasurementOuterDimensionsRequest:
|
||||
type: object
|
||||
required: [measurementId, version]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
measurementId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
version: {type: integer, minimum: 0}
|
||||
outerLengthCm: {type: number, minimum: 0.001, nullable: true}
|
||||
outerWidthCm: {type: number, minimum: 0.001, nullable: true}
|
||||
outerHeightCm: {type: number, minimum: 0.001, nullable: true}
|
||||
InboundMeasurementItemRequest:
|
||||
type: object
|
||||
required: [inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm]
|
||||
|
||||
@@ -27,7 +27,7 @@ paths:
|
||||
/api/platform/v1/containers/{id}/pda/photos/{fileId}/download:
|
||||
get:
|
||||
operationId: downloadContainerPdaPhoto
|
||||
description: '权限:platform:firstleg:container:list;Firstleg按柜次、装柜计划、入库单和文件归属代理下载,不暴露内部服务令牌'
|
||||
description: '权限:platform:firstleg:container:list;PDA完成及国内离仓后仍可读取ACTIVE历史照片;Firstleg按柜次、装柜计划、入库单和文件归属代理下载,不暴露内部服务令牌'
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}]
|
||||
responses: {'200': {description: photo bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}, '404': {description: photo missing or not owned by the container}}
|
||||
/api/platform/v1/containers/{id}/documents/{documentType}:
|
||||
|
||||
@@ -14,6 +14,7 @@ paths:
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}:
|
||||
get:
|
||||
summary: 查询单柜码头拖车详情
|
||||
description: 已确认国内离仓的货柜即使船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情;实际操作仍按到港、清关和卸柜节点校验
|
||||
x-permission: platform:firstleg:terminal-drayage:list
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||
@@ -63,6 +64,7 @@ paths:
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/emails:
|
||||
get:
|
||||
summary: 查询最近100封车队邮件及发信开关
|
||||
description: 已确认国内离仓的货柜允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件
|
||||
x-permission: platform:firstleg:terminal-drayage:list
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
responses:
|
||||
|
||||
+33
@@ -1,20 +1,52 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.service.ContainerOverviewService;
|
||||
import com.baiyue.firstleg.service.ContainerService;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("货柜基础资料接口契约")
|
||||
class ContainerControllerContractTest {
|
||||
@Test
|
||||
@DisplayName("PDA照片作为历史证据下载时不受国内工作台分区限制")
|
||||
void downloadsPdaPhotoWithoutDomesticWorkbenchGate() {
|
||||
ContainerService containers = mock(ContainerService.class);
|
||||
ContainerOverviewService overview = mock(ContainerOverviewService.class);
|
||||
PlatformContainerController controller = new PlatformContainerController();
|
||||
ReflectionTestUtils.setField(controller, "service", containers);
|
||||
ReflectionTestUtils.setField(controller, "overview", overview);
|
||||
when(overview.downloadPhoto(41L, 101L)).thenReturn(new ContainerOverviewService.PhotoDownload(
|
||||
"door.jpg", MediaType.IMAGE_JPEG, new byte[]{1, 2, 3}));
|
||||
AuthenticatedUser user = new AuthenticatedUser(7L, 0L, 0L, 8L,
|
||||
"admin", "PLATFORM", "WEB", Set.of("platform:firstleg:container:list"));
|
||||
|
||||
var response = controller.downloadPdaPhoto(user, 41L, 101L);
|
||||
|
||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
||||
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.IMAGE_JPEG);
|
||||
assertThat(response.getBody()).containsExactly(1, 2, 3);
|
||||
verify(overview).downloadPhoto(41L, 101L);
|
||||
verifyNoInteractions(containers);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("查询、新增和编辑按钮权限彼此独立")
|
||||
void separatesButtonPermissions() {
|
||||
@@ -50,6 +82,7 @@ class ContainerControllerContractTest {
|
||||
"/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:",
|
||||
"AWAITING_REVIEW", "version: {type: integer, minimum: 0}",
|
||||
"downloadPath: {type: string, nullable: true",
|
||||
"PDA完成及国内离仓后仍可读取ACTIVE历史照片",
|
||||
"仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空")
|
||||
.doesNotContain("?containerNo", "containerNo in: query");
|
||||
}
|
||||
|
||||
+5
-5
@@ -33,15 +33,15 @@ class CustomerDimensionConfigControllerContractTest {
|
||||
|
||||
@Test
|
||||
void internalInitializationRequiresTokenAndReturnsCreationIdentity() {
|
||||
InitializeRequest request = new InitializeRequest(8L, 100L, "registration-1");
|
||||
when(service.initializeDefault(8L, 100L, "registration-1"))
|
||||
.thenReturn(new InitializeResponse(91L, true));
|
||||
InitializeRequest request = new InitializeRequest(8L, 100L, "registration-1", "柏悦物流");
|
||||
when(service.initializeDefault(8L, 100L, "registration-1", "柏悦物流"))
|
||||
.thenReturn(new InitializeResponse(91L, 191L, true));
|
||||
|
||||
assertThatThrownBy(() -> internalController.initializeDefault("wrong", request))
|
||||
.isInstanceOf(BizException.class);
|
||||
assertThat(internalController.initializeDefault("test-token", request).data())
|
||||
.isEqualTo(new InitializeResponse(91L, true));
|
||||
verify(service).initializeDefault(8L, 100L, "registration-1");
|
||||
.isEqualTo(new InitializeResponse(91L, 191L, true));
|
||||
verify(service).initializeDefault(8L, 100L, "registration-1", "柏悦物流");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+16
@@ -61,6 +61,22 @@ class InboundMeasurementEvidenceOpenApiContractTest {
|
||||
assertThat(properties(schemas, "InboundMeasurementItemRequest")).containsKeys(
|
||||
"inboundDetailId", "itemNo", "boxSpecification", "packageCount", "lengthCm",
|
||||
"widthCm", "heightCm", "intelligentImageFileId", "remark");
|
||||
String strategyPath = "/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/calculation-strategy";
|
||||
assertThat(paths).containsKey(strategyPath);
|
||||
Map<String, Object> strategyOperation = stringMap(stringMap(paths.get(strategyPath)).get("put"));
|
||||
assertThat(strategyOperation.get("description").toString())
|
||||
.contains("不覆盖整批测量数据", "不改变测量提交或审核状态", "客户已确认费用且该票已装柜完成");
|
||||
assertThat(properties(schemas, "InboundMeasurementCalculationStrategyRequest"))
|
||||
.containsKeys("measurementId", "version", "coefficientMode", "chargeRuleId");
|
||||
String outerDimensionsPath = "/api/platform/v1/first-leg/inbounds/{inboundId}"
|
||||
+ "/measurement/items/{itemId}/outer-dimensions";
|
||||
assertThat(paths).containsKey(outerDimensionsPath);
|
||||
Map<String, Object> outerDimensionsOperation = stringMap(
|
||||
stringMap(paths.get(outerDimensionsPath)).get("put"));
|
||||
assertThat(outerDimensionsOperation.get("description").toString())
|
||||
.contains("不覆盖整批测量数据", "不改变测量提交或审核状态", "客户已确认费用且该票已装柜完成");
|
||||
assertThat(properties(schemas, "InboundMeasurementOuterDimensionsRequest"))
|
||||
.containsKeys("measurementId", "version", "outerLengthCm", "outerWidthCm", "outerHeightCm");
|
||||
|
||||
Map<String, Object> itemProperties = properties(schemas, "InboundMeasurementItem");
|
||||
assertThat(itemProperties).containsKeys(
|
||||
|
||||
+3
@@ -22,6 +22,8 @@ class InboundOrderControllerContractTest {
|
||||
AuthenticatedUser.class, long.class, long.class);
|
||||
assertAuthority("printCartonLabels", "hasAuthority('platform:firstleg:inbound:view')",
|
||||
AuthenticatedUser.class, long.class, long.class, List.class, String.class);
|
||||
assertAuthority("printCartonLabelsBatch", "hasAuthority('platform:firstleg:inbound:view')",
|
||||
AuthenticatedUser.class, List.class, String.class);
|
||||
|
||||
Method page = PlatformInboundOrderController.class.getDeclaredMethod(
|
||||
"page", AuthenticatedUser.class, Long.class, InboundOrderQuery.class);
|
||||
@@ -47,6 +49,7 @@ class InboundOrderControllerContractTest {
|
||||
.contains("/api/platform/inbounds:")
|
||||
.contains("/api/platform/inbounds/{inboundId}:")
|
||||
.contains("/api/platform/inbounds/{inboundId}/carton-label-pdf:")
|
||||
.contains("/api/platform/inbounds/carton-label-pdf:")
|
||||
.contains("/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans:")
|
||||
.contains("required: false")
|
||||
.contains("InboundOrderSummary:")
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ class ContainerCustomsControllerContractTest {
|
||||
String yaml=new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
|
||||
new Yaml().load(yaml);
|
||||
assertThat(yaml).contains("ContainerView:","DocumentView:","FileView:","InboundView:","ActionRequest:","ScopeRequest:")
|
||||
.contains("merchantStatus:","WAITING_UPLOAD","CUSTOMS_COMPLETED","loadingPlanId:")
|
||||
.contains("merchantStatus:","WAITING_UPLOAD","CUSTOMS_COMPLETED","loadingPlanId:","hasActualCargo:","nullable: true")
|
||||
.contains("LOADING_LIST_BUY_DECLARATION_ITEMS")
|
||||
.doesNotContain("/cases/","CUSTOMS_DECLARATION");
|
||||
}
|
||||
|
||||
+3
@@ -23,6 +23,9 @@ class ContainerCustomsMapperContractTest {
|
||||
@Test void actualCompletedLoadingItemsAndTaxModeAreTheOnlyTicketFacts() throws Exception {
|
||||
String plan=sql("planInbounds",long.class);
|
||||
assertThat(plan).contains("fl_loading_plan_item","fl_inbound_order","p.id=#{planId}","item.deleted=0");
|
||||
String planIdentity=sql("plan",long.class);
|
||||
assertThat(planIdentity).contains("fl_loading_plan","fl_container_booking","p.id=#{planId}")
|
||||
.doesNotContain("fl_loading_plan_item","fl_inbound_order");
|
||||
String missing=sql("missingRefundCount",long.class,long.class);
|
||||
assertThat(missing).contains("i.tax_mode='TAX_REFUND'","fl_customs_customer_document_inbound","d.status='ACTIVE'");
|
||||
}
|
||||
|
||||
+39
@@ -44,6 +44,45 @@ class ContainerCustomsServiceTest {
|
||||
assertMerchantOrderNotFound(7L,22L);
|
||||
}
|
||||
|
||||
@Test void completedPlanWithoutActualCargoReturnsEmptyViewInsteadOfNotFound(){
|
||||
var plan=new ContainerCustomsMapper.PlanRow(9L,"LP9",8L,"COMPLETED","CAB","C");
|
||||
when(mapper.planInbounds(9L)).thenReturn(List.of());
|
||||
when(mapper.plan(9L)).thenReturn(plan);
|
||||
when(mapper.container(9L)).thenReturn(container(ContainerCustomsService.READY,0));
|
||||
|
||||
var view=service.platformView(9L);
|
||||
|
||||
assertThat(view.hasActualCargo()).isFalse();
|
||||
assertThat(view.planNo()).isEqualTo("LP9");
|
||||
assertThat(view.inbounds()).isEmpty();
|
||||
assertThat(view.documents()).isEmpty();
|
||||
assertThat(view.taxRefundCount()).isZero();
|
||||
verify(mapper).insertContainer(anyLong(),eq(9L),eq(8L),eq(ContainerCustomsService.READY));
|
||||
}
|
||||
|
||||
@Test void editingPlanReturnsNoCustomsAggregateInsteadOfConflict(){
|
||||
when(mapper.planInbounds(9L)).thenReturn(List.of(new ContainerCustomsMapper.InboundRow(
|
||||
9L,"LP9",8L,"EDITING","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND")));
|
||||
|
||||
assertThat(service.platformView(9L)).isNull();
|
||||
|
||||
verify(mapper,never()).insertContainer(anyLong(),anyLong(),anyLong(),anyString());
|
||||
verify(mapper,never()).container(9L);
|
||||
}
|
||||
|
||||
@Test void completedPlanWithoutActualCargoCannotStartCustoms(){
|
||||
when(mapper.claimCommand(anyLong(),eq(5L),eq("START_CONTAINER_CUSTOMS_9"),eq("empty-plan"),anyString())).thenReturn(1);
|
||||
when(mapper.planInbounds(9L)).thenReturn(List.of());
|
||||
when(mapper.plan(9L)).thenReturn(new ContainerCustomsMapper.PlanRow(9L,"LP9",8L,"COMPLETED","CAB","C"));
|
||||
when(mapper.container(9L)).thenReturn(container(ContainerCustomsService.READY,0));
|
||||
|
||||
assertThatThrownBy(()->service.start(9L,5L,"admin","empty-plan",new ActionRequest(0,true,null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("没有实际货物,无需报关");
|
||||
|
||||
verify(mapper,never()).lockContainer(9L);
|
||||
verify(mapper,never()).start(anyLong(),anyInt(),anyLong(),anyString());
|
||||
}
|
||||
|
||||
@Test void merchantCompletedTaxRefundOrderReturnsContainerView(){
|
||||
var row=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND");
|
||||
when(mapper.completedPlanForOrder(7L,21L)).thenReturn(9L);
|
||||
|
||||
-4
@@ -6,7 +6,6 @@ import com.baiyue.firstleg.loading.repository.LoadingRepository;
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundProgress;
|
||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import com.baiyue.firstleg.service.FbaPricingService;
|
||||
import com.baiyue.firstleg.service.InboundHoldGuard;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -29,7 +28,6 @@ class LoadingDeliveryServiceTest {
|
||||
@MockBean LoadingCompletionGateService completionGate;
|
||||
@MockBean InboundStatusHistoryMapper histories;
|
||||
@MockBean CabinetLoadingEvidenceService cabinetEvidence;
|
||||
@MockBean FbaPricingService pricingService;
|
||||
@MockBean ObjectMapper objectMapper;
|
||||
@MockBean InboundHoldGuard inboundHoldGuard;
|
||||
@Resource LoadingDeliveryService service;
|
||||
@@ -50,7 +48,6 @@ class LoadingDeliveryServiceTest {
|
||||
new com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest(3, "done"));
|
||||
|
||||
verify(cabinetEvidence).requireComplete(99L);
|
||||
verify(pricingService).autoConfirmAfterLoading(10L, 20L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -91,7 +88,6 @@ class LoadingDeliveryServiceTest {
|
||||
|
||||
verify(repository).findInboundForUpdate(10L, 21L);
|
||||
verify(repository).completeLoadedChild(10L, 20L, 9L, 9);
|
||||
verify(pricingService).autoConfirmAfterLoading(10L, 21L);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+18
@@ -146,6 +146,24 @@ class OutboundDispatchServiceTest {
|
||||
verify(carrierSchedules, times(1)).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmCreatesPlatformScopedCarrierTaskForConsolidatedBooking() {
|
||||
CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class);
|
||||
OutboundDispatchService service = service(carrierSchedules);
|
||||
stubContainer();
|
||||
when(repository.ocean(22L)).thenReturn(new OutboundDispatchReadMapper.OceanBinding(66L, "RELEASED", null));
|
||||
when(repository.lockBindings(22L)).thenReturn(bindings());
|
||||
when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "READY_FOR_DISPATCH", 0));
|
||||
when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "READY_FOR_DISPATCH", 0));
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-platform-booking"), anyString())).thenReturn(true);
|
||||
when(repository.confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull())).thenReturn(1);
|
||||
when(repository.completeOperation(31L, 9L, "CONFIRM_DISPATCH", "confirm-platform-booking")).thenReturn(1);
|
||||
|
||||
service.confirm(22L, 9L, "admin", "confirm-platform-booking", new ActionRequest(0, null));
|
||||
|
||||
verify(carrierSchedules).ensureFromOutboundConfirm(null, 66L, 22L, 9L, "admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmDoesNotWriteAnyRowWhenOneInboundGateFails() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
|
||||
-2
@@ -38,8 +38,6 @@ class CarrierScheduleQueryOpenApiContractTest {
|
||||
.contains("cabinetNo, orderNo, bookingNo and bill of lading are never fallbacks")
|
||||
.contains("latestActualNode:")
|
||||
.contains("latestActualAt:")
|
||||
.contains("manual actual node is equal to or earlier than latestActualNode")
|
||||
.contains("strictly greater sequence than latestActualNode")
|
||||
.contains("internal execution fields are intentionally absent")
|
||||
.doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED")
|
||||
.doesNotContain("enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]")
|
||||
|
||||
+4
-3
@@ -21,7 +21,7 @@ class CarrierScheduleQuerySqlContractTest {
|
||||
.getAnnotation(Select.class).value());
|
||||
|
||||
assertThat(sql)
|
||||
.contains("container_booking_id IN")
|
||||
.contains("WHERE b.id IN")
|
||||
.contains("f.actual_at IS NOT NULL")
|
||||
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT'")
|
||||
@@ -29,9 +29,10 @@ class CarrierScheduleQuerySqlContractTest {
|
||||
.contains("MAX(CASE WHEN f.standard_node='PICKUP_READY'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='PICKED_UP'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='EMPTY_RETURNED'")
|
||||
.contains("ORDER BY q.container_booking_id")
|
||||
.contains("ORDER BY b.id")
|
||||
.doesNotContain("estimated_at");
|
||||
assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(1);
|
||||
assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(3);
|
||||
assertThat(count(sql, "SELECT f2.id FROM fl_carrier_schedule_node_fact f2")).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+18
@@ -93,6 +93,24 @@ class CarrierScheduleNodeOutboxServiceTest {
|
||||
verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void directManualActualFactDoesNotRequireTrackingQuery() throws Exception {
|
||||
CarrierScheduleNodeFactEntity fact = fact();
|
||||
fact.setProviderCode("MANUAL"); fact.setProviderEventId("manual-event");
|
||||
fact.setSourceReference("MANUAL:7");
|
||||
|
||||
service.publishContainerNode(null, 2088000000000000003L,
|
||||
fact, "ZCSU7881970");
|
||||
|
||||
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||
verify(mapper).insert(captor.capture());
|
||||
EventEnvelope envelope = objectMapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
|
||||
assertThat(envelope.payload().path("queryId").isNull()).isTrue();
|
||||
assertThat(envelope.payload().path("oceanBookingId").isNull()).isTrue();
|
||||
assertThat(envelope.payload().path("sourceContainerBookingId").asText())
|
||||
.isEqualTo("2088000000000000003");
|
||||
}
|
||||
|
||||
@Test
|
||||
void initialOutboundDepartureUsesVersionZeroWithoutEnvelopeFailure() throws Exception {
|
||||
CarrierScheduleQueryEntity query = query();
|
||||
|
||||
+20
-30
@@ -210,6 +210,24 @@ class CarrierScheduleQueryServiceTest {
|
||||
&& "国内确认出仓自动创建".equals(history.getReason())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformOwnedConsolidatedBookingCreatesContainerTaskInTechnicalScope() {
|
||||
OceanBookingEntity booking = new OceanBookingEntity();
|
||||
booking.setCustomerId(null); booking.setCarrierCode("SML"); booking.setShippingCompanyName("SML");
|
||||
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
|
||||
container.setContainerNo("ZCSU7881970");
|
||||
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
|
||||
when(portSchedules.containerBooking(20L)).thenReturn(container);
|
||||
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
|
||||
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true);
|
||||
|
||||
CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(null, 10L, 20L, 0L, "SYSTEM");
|
||||
|
||||
assertThat(result.getCustomerId()).isZero();
|
||||
verify(repository).findByBusinessScope(0L, 10L, 20L);
|
||||
verify(repository).insert(argThat((CarrierScheduleQueryEntity task) -> task.getCustomerId() == 0L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundUnknownCarrierCreatesManualTaskAndPreservesBookingCarrierIdentity() {
|
||||
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
|
||||
@@ -298,7 +316,7 @@ class CarrierScheduleQueryServiceTest {
|
||||
@Test
|
||||
void outboundAndCarrierEnsureShareRequiredTransactionalRollbackBoundary() throws Exception {
|
||||
Transactional carrierBoundary = CarrierScheduleQueryService.class.getMethod("ensureFromOutboundConfirm",
|
||||
long.class, long.class, long.class, long.class, String.class).getAnnotation(Transactional.class);
|
||||
Long.class, long.class, long.class, long.class, String.class).getAnnotation(Transactional.class);
|
||||
Transactional outboundBoundary = OutboundDispatchService.class.getMethod("confirm",
|
||||
long.class, long.class, String.class, String.class, ActionRequest.class).getAnnotation(Transactional.class);
|
||||
|
||||
@@ -433,34 +451,6 @@ class CarrierScheduleQueryServiceTest {
|
||||
verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualActualRejectsSameAndEarlierNodesIncludingSameKeyRetryWithoutDrift() {
|
||||
CarrierScheduleQueryEntity task = task(0, 3);
|
||||
task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP");
|
||||
task.setLatestNodeObservedAt(NOW.minusDays(1));
|
||||
when(repository.lock(1L)).thenReturn(task);
|
||||
|
||||
ManualNodeRequest sameNode = new ManualNodeRequest(3, "PICKED_UP", NOW, null,
|
||||
NOW.plusDays(1), null, "Los Angeles", "重复确认提柜");
|
||||
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-picked-up-again", sameNode))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("人工实际节点必须严格晚于当前最新实际节点");
|
||||
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-picked-up-again", sameNode))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("人工实际节点必须严格晚于当前最新实际节点");
|
||||
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-pickup-ready-late",
|
||||
new ManualNodeRequest(3, "PICKUP_READY", NOW, null, NOW.plusDays(2), null,
|
||||
"Los Angeles", "倒序补录可提柜")))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("人工实际节点必须严格晚于当前最新实际节点");
|
||||
|
||||
verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
|
||||
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
|
||||
verify(repository, never()).insert(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity.class));
|
||||
verify(repository, never()).completeOperation(anyLong(), anyLong(), anyString(), anyString());
|
||||
verify(nodeOutbox, never()).publish(any(), any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualEstimatedFactAdvancesVersionWithoutAdvancingActualProgress() {
|
||||
CarrierScheduleQueryEntity before = task(0, 3);
|
||||
@@ -531,7 +521,7 @@ class CarrierScheduleQueryServiceTest {
|
||||
existing.setEventKey("ZIM:evt-duplicate"); existing.setRawPayloadHash("a".repeat(64));
|
||||
when(repository.lock(1L)).thenReturn(task);
|
||||
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(false);
|
||||
when(repository.node(1L, "ZIM:evt-duplicate")).thenReturn(existing);
|
||||
when(repository.nodeByContainer(task.getContainerBookingId(), "ZIM:evt-duplicate")).thenReturn(existing);
|
||||
ProviderObservationRequest replay = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN",
|
||||
"AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "a".repeat(64));
|
||||
|
||||
|
||||
+24
-2
@@ -210,20 +210,42 @@ class FbaPricingRepositoryRuleListTest {
|
||||
.thenReturn(1);
|
||||
|
||||
assertThat(repository.markInboundCalculated(
|
||||
10001L, 88L, "WAIT_PALLETIZE", "CBM", new java.math.BigDecimal("1.0000")))
|
||||
10001L, 88L, "WAIT_PALLETIZE", "CBM", new java.math.BigDecimal("10.000"),
|
||||
new java.math.BigDecimal("1.0000"), new java.math.BigDecimal("1.0000")))
|
||||
.isEqualTo(1);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<LambdaUpdateWrapper<InboundPricingEntity>> wrapper =
|
||||
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
||||
verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture());
|
||||
assertThat(wrapper.getValue().getSqlSet()).contains("feeStatus", "chargeableVolumeCbm")
|
||||
assertThat(wrapper.getValue().getSqlSet()).contains(
|
||||
"feeStatus", "chargeableWeightKg", "chargeableVolumeCbm")
|
||||
.doesNotContain("processStatus");
|
||||
assertThat(wrapper.getValue().getCustomSqlSegment())
|
||||
.contains("tenantId", "id", "processStatus", "feeStatus");
|
||||
assertThat(params(wrapper.getValue()).values()).contains(10001L, 88L, "WAIT_PALLETIZE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void kilogramPricingStoresChargeableWeightAndSelectedVolumeTogether() {
|
||||
when(pricing.update(org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any()))
|
||||
.thenReturn(1);
|
||||
|
||||
assertThat(repository.markInboundCalculated(
|
||||
10001L, 88L, "WAIT_SPLIT", "KG", new java.math.BigDecimal("297.000"),
|
||||
new java.math.BigDecimal("9.0000"), new java.math.BigDecimal("1503.000")))
|
||||
.isEqualTo(1);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<LambdaUpdateWrapper<InboundPricingEntity>> wrapper =
|
||||
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
||||
verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture());
|
||||
assertThat(wrapper.getValue().getSqlSet())
|
||||
.contains("chargeableWeightKg", "chargeableVolumeCbm");
|
||||
assertThat(params(wrapper.getValue()).values()).contains(
|
||||
new java.math.BigDecimal("1503.000"), new java.math.BigDecimal("9.0000"));
|
||||
}
|
||||
|
||||
private Collection<Object> queryValues(Long tenantId, String ruleCode) {
|
||||
when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of());
|
||||
|
||||
|
||||
+3
-3
@@ -89,16 +89,16 @@ class LoadingPickItemMapperContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdaWritesAndReadsRequireWarehouseScope() throws Exception {
|
||||
void pdaWritesAndReadsRelyOnUpstreamTaskScopeInsteadOfWarehouseScope() throws Exception {
|
||||
Method list = LoadingPickItemMapper.class.getDeclaredMethod("selectTaskItemsWithAccount", long.class, long.class);
|
||||
Method pick = LoadingPickItemMapper.class.getDeclaredMethod("pick", long.class, int.class,
|
||||
int.class, long.class);
|
||||
|
||||
assertThat(list.getAnnotation(Select.class).value()[0])
|
||||
.contains("booking.warehouse_id = #{warehouseId}")
|
||||
.doesNotContain("booking.warehouse_id = #{warehouseId}")
|
||||
.contains("plan_item.deleted = 0");
|
||||
assertThat(pick.getAnnotation(Update.class).value()[0])
|
||||
.contains("booking.warehouse_id = #{warehouseId}")
|
||||
.doesNotContain("booking.warehouse_id = #{warehouseId}")
|
||||
.contains("plan_item.deleted = 0");
|
||||
}
|
||||
}
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
|
||||
import com.baiyue.firstleg.repository.FbaPricingRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(CustomerChargeRuleInitializationService.class)
|
||||
class CustomerChargeRuleInitializationServiceTest {
|
||||
@MockBean private FbaPricingRepository repository;
|
||||
@Resource private CustomerChargeRuleInitializationService service;
|
||||
|
||||
@Test
|
||||
void initializesDefaultWeightCoefficientRuleForNewCustomer() {
|
||||
CustomerChargeRuleEntity result = service.initializeDefault(
|
||||
8L, 100L, "iam-customer-create:8", "柏悦物流");
|
||||
|
||||
ArgumentCaptor<CustomerChargeRuleEntity> captor =
|
||||
ArgumentCaptor.forClass(CustomerChargeRuleEntity.class);
|
||||
verify(repository).insertRule(captor.capture());
|
||||
CustomerChargeRuleEntity saved = captor.getValue();
|
||||
assertThat(result).isSameAs(saved);
|
||||
assertThat(saved.getTenantId()).isEqualTo(8L);
|
||||
assertThat(saved.getCustomerId()).isEqualTo(8L);
|
||||
assertThat(saved.getCustomerName()).isEqualTo("柏悦物流");
|
||||
assertThat(saved.getRuleCode()).isEqualTo("DEFAULT-DIMENSION-WEIGHT");
|
||||
assertThat(saved.getRuleVersion()).isEqualTo(1);
|
||||
assertThat(saved.getDefaultRule()).isTrue();
|
||||
assertThat(saved.getLengthFactor()).isEqualByComparingTo("1.100000");
|
||||
assertThat(saved.getWidthFactor()).isEqualByComparingTo("1.100000");
|
||||
assertThat(saved.getHeightFactor()).isEqualByComparingTo("1.100000");
|
||||
assertThat(saved.getConversionFactor()).isEqualByComparingTo("167.000000");
|
||||
assertThat(saved.getRemark()).contains(
|
||||
"SMALL:30,20,20;系数:1.1;重量:5",
|
||||
"EXTRA_LARGE:60,50,50;系数:1.1;重量:15");
|
||||
assertThat(saved.getInitializationKey()).isEqualTo("iam-customer-create:8");
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryReturnsRuleOwnedBySameInitializationWithoutDuplicating() {
|
||||
CustomerChargeRuleEntity existing = new CustomerChargeRuleEntity();
|
||||
existing.setId(191L);
|
||||
when(repository.findRuleByInitializationKey(8L, "iam-customer-create:8"))
|
||||
.thenReturn(existing);
|
||||
|
||||
assertThat(service.initializeDefault(8L, 100L, "iam-customer-create:8", "柏悦物流"))
|
||||
.isSameAs(existing);
|
||||
verify(repository, never()).insertRule(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void compensationDeletesOnlyAnUnchangedInitializedRule() {
|
||||
CustomerChargeRuleEntity value = service.initializeDefault(
|
||||
8L, 100L, "iam-customer-create:8", "柏悦物流");
|
||||
value.setVersion(0);
|
||||
when(repository.findRuleByInitializationKeyForUpdate(8L, "iam-customer-create:8"))
|
||||
.thenReturn(value);
|
||||
when(repository.deleteUnchangedInitializedRule(8L, "iam-customer-create:8"))
|
||||
.thenReturn(1);
|
||||
|
||||
CustomerChargeRuleEntity locked = service.lockForCompensation(8L, "iam-customer-create:8");
|
||||
assertThat(service.isUnchangedDefault(locked)).isTrue();
|
||||
service.deleteUnchangedDefault(8L, "iam-customer-create:8");
|
||||
|
||||
verify(repository).deleteUnchangedInitializedRule(8L, "iam-customer-create:8");
|
||||
}
|
||||
}
|
||||
+25
-6
@@ -22,6 +22,7 @@ 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.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
@@ -32,6 +33,8 @@ import static org.mockito.Mockito.when;
|
||||
class CustomerDimensionConfigServiceTest {
|
||||
@MockBean
|
||||
private CustomerDimensionConfigRepository repository;
|
||||
@MockBean
|
||||
private CustomerChargeRuleInitializationService chargeRuleInitialization;
|
||||
@Resource
|
||||
private CustomerDimensionConfigService service;
|
||||
|
||||
@@ -40,10 +43,12 @@ class CustomerDimensionConfigServiceTest {
|
||||
List<CustomerDimensionConfigEntity> persisted = defaultConfigs("registration-1");
|
||||
when(repository.findAll(8L)).thenReturn(List.of(), persisted);
|
||||
when(repository.insertDefaults(any(), eq(100L))).thenReturn(4);
|
||||
when(chargeRuleInitialization.initializeDefault(8L, 100L, "registration-1", "柏悦物流"))
|
||||
.thenReturn(defaultChargeRule());
|
||||
|
||||
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1");
|
||||
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1", "柏悦物流");
|
||||
|
||||
assertThat(result).isEqualTo(new InitializeResponse(91L, true));
|
||||
assertThat(result).isEqualTo(new InitializeResponse(91L, 191L, true));
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<CustomerDimensionConfigEntity>> captor = ArgumentCaptor.forClass(List.class);
|
||||
verify(repository).insertDefaults(captor.capture(), eq(100L));
|
||||
@@ -67,10 +72,12 @@ class CustomerDimensionConfigServiceTest {
|
||||
@Test
|
||||
void retryAfterLostResponseKeepsCreationOwnershipForSameKey() {
|
||||
when(repository.findAll(8L)).thenReturn(defaultConfigs("registration-1"));
|
||||
when(chargeRuleInitialization.initializeDefault(8L, 100L, "registration-1", "柏悦物流"))
|
||||
.thenReturn(defaultChargeRule());
|
||||
|
||||
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1");
|
||||
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1", "柏悦物流");
|
||||
|
||||
assertThat(result).isEqualTo(new InitializeResponse(91L, true));
|
||||
assertThat(result).isEqualTo(new InitializeResponse(91L, 191L, true));
|
||||
verify(repository, never()).insertDefaults(any(), eq(100L));
|
||||
}
|
||||
|
||||
@@ -78,10 +85,11 @@ class CustomerDimensionConfigServiceTest {
|
||||
void differentInitializationKeyDoesNotTakeOwnershipOrOverwrite() {
|
||||
when(repository.findAll(8L)).thenReturn(defaultConfigs("registration-1"));
|
||||
|
||||
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-2");
|
||||
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-2", "柏悦物流");
|
||||
|
||||
assertThat(result).isEqualTo(new InitializeResponse(91L, false));
|
||||
assertThat(result).isEqualTo(new InitializeResponse(91L, null, false));
|
||||
verify(repository, never()).insertDefaults(any(), eq(100L));
|
||||
verify(chargeRuleInitialization, never()).initializeDefault(anyLong(), anyLong(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -173,10 +181,14 @@ class CustomerDimensionConfigServiceTest {
|
||||
when(repository.findDefaultsForCompensation(8L, "registration-1"))
|
||||
.thenReturn(defaultConfigs("registration-1"));
|
||||
when(repository.deleteUnchangedDefaults(8L, "registration-1")).thenReturn(4);
|
||||
when(chargeRuleInitialization.lockForCompensation(8L, "registration-1"))
|
||||
.thenReturn(defaultChargeRule());
|
||||
when(chargeRuleInitialization.isUnchangedDefault(any())).thenReturn(true);
|
||||
|
||||
service.compensateDefault(8L, "registration-1");
|
||||
|
||||
verify(repository).deleteUnchangedDefaults(8L, "registration-1");
|
||||
verify(chargeRuleInitialization).deleteUnchangedDefault(8L, "registration-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -228,4 +240,11 @@ class CustomerDimensionConfigServiceTest {
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private static com.baiyue.firstleg.entity.CustomerChargeRuleEntity defaultChargeRule() {
|
||||
com.baiyue.firstleg.entity.CustomerChargeRuleEntity value =
|
||||
new com.baiyue.firstleg.entity.CustomerChargeRuleEntity();
|
||||
value.setId(191L);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,48 +77,6 @@ class FbaPricingServiceTest {
|
||||
verify(repository, never()).findLatestFee(TENANT, INBOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("装柜完成后系统自动确认客户未确认的已发送费用")
|
||||
void automaticallyConfirmsSentFeeAfterLoading() {
|
||||
InboundFeeEntity sent = savedFee("SENT");
|
||||
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent);
|
||||
when(repository.confirmFeeByCustomer(TENANT, 501L, 1, 0L,
|
||||
"装柜完成自动确认", NOW)).thenReturn(1);
|
||||
|
||||
service.autoConfirmAfterLoading(TENANT, INBOUND);
|
||||
|
||||
verify(repository).confirmFeeByCustomer(TENANT, 501L, 1, 0L,
|
||||
"装柜完成自动确认", NOW);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("客户已经确认时装柜完成不覆盖原确认信息")
|
||||
void keepsExistingCustomerConfirmationAfterLoading() {
|
||||
InboundFeeEntity sent = savedFee("SENT");
|
||||
sent.setCustomerConfirmedBy(88L);
|
||||
sent.setCustomerConfirmedByName("客户成员");
|
||||
sent.setCustomerConfirmedAt(NOW.minusHours(1));
|
||||
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent);
|
||||
|
||||
service.autoConfirmAfterLoading(TENANT, INBOUND);
|
||||
|
||||
verify(repository, never()).confirmFeeByCustomer(anyLong(), anyLong(), anyInt(),
|
||||
anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("没有已发送费用时装柜完成不受影响")
|
||||
void ignoresMissingOrUnsentFeeAfterLoading() {
|
||||
when(repository.findLatestFeeForUpdate(TENANT, INBOUND))
|
||||
.thenReturn(null, savedFee("CONFIRMED"));
|
||||
|
||||
service.autoConfirmAfterLoading(TENANT, INBOUND);
|
||||
service.autoConfirmAfterLoading(TENANT, INBOUND);
|
||||
|
||||
verify(repository, never()).confirmFeeByCustomer(anyLong(), anyLong(), anyInt(),
|
||||
anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商")
|
||||
void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() {
|
||||
@@ -258,7 +216,7 @@ class FbaPricingServiceTest {
|
||||
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
|
||||
verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_SPLIT", "KG",
|
||||
new BigDecimal("167.000"));
|
||||
new BigDecimal("100.000"), new BigDecimal("1.0000"), new BigDecimal("167.000"));
|
||||
verify(feeDetails).materializeCalculatedFreight(TENANT, INBOUND, 501L, OPERATOR, NOW);
|
||||
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
|
||||
}
|
||||
@@ -289,17 +247,27 @@ class FbaPricingServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("装柜完成后禁止确认费用")
|
||||
void rejectsConfirmAfterLoadingCompleted() {
|
||||
@DisplayName("装柜完成但客户未确认时仍允许确认新费用版本")
|
||||
void confirmsNewFeeVersionAfterLoadingCompleted() {
|
||||
InboundPricingEntity inbound = inbound("10.000", "1.0000");
|
||||
inbound.setProcessStatus("LOADED");
|
||||
inbound.setFeeStatus("CALCULATED");
|
||||
InboundFeeEntity fee = savedFee("CALCULATED");
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee);
|
||||
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CONFIRM", "confirm-loaded"))
|
||||
.thenReturn(null);
|
||||
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(899L); return null; })
|
||||
.when(repository).insertIdempotency(any());
|
||||
when(repository.confirmFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1);
|
||||
when(repository.markInboundConfirmed(TENANT, INBOUND)).thenReturn(1);
|
||||
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
|
||||
when(repository.findFee(TENANT, 501L)).thenReturn(fee);
|
||||
|
||||
assertThatThrownBy(() -> service.confirm(TENANT, OPERATOR, INBOUND, "confirm-loaded"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("装柜流程");
|
||||
verify(repository, never()).markInboundConfirmed(TENANT, INBOUND);
|
||||
FeeSnapshotResponse response = service.confirm(TENANT, OPERATOR, INBOUND, "confirm-loaded");
|
||||
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("LOADED");
|
||||
verify(repository).markInboundConfirmed(TENANT, INBOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -331,15 +299,15 @@ class FbaPricingServiceTest {
|
||||
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_PALLETIZE");
|
||||
verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_PALLETIZE", "CBM",
|
||||
new BigDecimal("1.0000"));
|
||||
new BigDecimal("10.000"), new BigDecimal("1.0000"), new BigDecimal("1.0000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("客户未确认时待装柜订单可重新计算并替换已发送费用")
|
||||
@DisplayName("客户未确认时装柜完成订单仍可重新计算并替换已发送费用")
|
||||
void recalculatesSentFeeBeforeCustomerConfirmation() {
|
||||
readyRepository();
|
||||
InboundPricingEntity inbound = inbound("10", "1");
|
||||
inbound.setProcessStatus("WAIT_PALLET");
|
||||
inbound.setProcessStatus("LOADED");
|
||||
inbound.setFeeStatus("SENT");
|
||||
InboundFeeEntity sent = savedFee("SENT");
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
@@ -354,18 +322,18 @@ class FbaPricingServiceTest {
|
||||
|
||||
assertThat(response.feeVersion()).isEqualTo(2);
|
||||
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_PALLET");
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("LOADED");
|
||||
verify(repository).supersedeCurrent(TENANT, 501L, "SENT");
|
||||
verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_PALLET", "CBM",
|
||||
new BigDecimal("1.0000"));
|
||||
verify(repository).markInboundCalculated(TENANT, INBOUND, "LOADED", "CBM",
|
||||
new BigDecimal("10.000"), new BigDecimal("1.0000"), new BigDecimal("1.0000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("客户已确认时拒绝重新计算已发送费用")
|
||||
@DisplayName("客户已确认且装柜完成时拒绝重新计算已发送费用")
|
||||
void rejectsRecalculationAfterCustomerConfirmation() {
|
||||
readyRepository();
|
||||
InboundPricingEntity inbound = inbound("10", "1");
|
||||
inbound.setProcessStatus("WAIT_PALLET");
|
||||
inbound.setProcessStatus("LOADED");
|
||||
inbound.setFeeStatus("SENT");
|
||||
InboundFeeEntity sent = savedFee("SENT");
|
||||
sent.setCustomerConfirmedAt(NOW);
|
||||
@@ -493,6 +461,7 @@ class FbaPricingServiceTest {
|
||||
inbound.setProcessStatus("WAIT_SPLIT");
|
||||
inbound.setFeeStatus("SENT");
|
||||
InboundFeeEntity current = savedFee("SENT");
|
||||
current.setCustomerConfirmedAt(NOW.minusHours(1));
|
||||
AtomicReference<InboundFeeEntity> inserted = new AtomicReference<>();
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current);
|
||||
@@ -501,7 +470,8 @@ class FbaPricingServiceTest {
|
||||
.when(repository).insertIdempotency(any());
|
||||
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
|
||||
when(repository.supersedeCurrent(TENANT, 501L, "SENT")).thenReturn(1);
|
||||
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("SENT"), eq("KG"), any()))
|
||||
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("SENT"),
|
||||
any(), any(), eq("KG"), any()))
|
||||
.thenReturn(1);
|
||||
doAnswer(invocation -> {
|
||||
InboundFeeEntity fee = invocation.getArgument(0);
|
||||
@@ -561,10 +531,10 @@ class FbaPricingServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("客户已确认费用时拒绝平台再次修改")
|
||||
@DisplayName("客户已确认且装柜完成时拒绝平台再次修改")
|
||||
void rejectsAdjustmentAfterCustomerConfirmation() {
|
||||
InboundPricingEntity inbound = inbound("10", "1");
|
||||
inbound.setProcessStatus("WAIT_SPLIT");
|
||||
inbound.setProcessStatus("LOADED");
|
||||
InboundFeeEntity current = savedFee("SENT");
|
||||
current.setCustomerConfirmedAt(NOW);
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
@@ -596,7 +566,8 @@ class FbaPricingServiceTest {
|
||||
.when(repository).insertIdempotency(any());
|
||||
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
|
||||
when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1);
|
||||
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("CALCULATED"), eq("KG"), any()))
|
||||
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("CALCULATED"),
|
||||
any(), any(), eq("KG"), any()))
|
||||
.thenReturn(1);
|
||||
doAnswer(invocation -> {
|
||||
InboundFeeEntity fee = invocation.getArgument(0);
|
||||
@@ -655,7 +626,8 @@ class FbaPricingServiceTest {
|
||||
.when(repository).insertIdempotency(any());
|
||||
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
|
||||
when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1);
|
||||
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("CALCULATED"), eq("KG"), any()))
|
||||
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("CALCULATED"),
|
||||
any(), any(), eq("KG"), any()))
|
||||
.thenReturn(1);
|
||||
doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(504L); return null; })
|
||||
.when(repository).insertFee(any());
|
||||
@@ -803,7 +775,8 @@ class FbaPricingServiceTest {
|
||||
when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2);
|
||||
when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1);
|
||||
when(repository.supersedeCurrent(eq(TENANT), anyLong(), anyString())).thenReturn(1);
|
||||
when(repository.markInboundContainerFeeRecalculated(eq(TENANT), anyLong(), anyString(), anyString(), any()))
|
||||
when(repository.markInboundContainerFeeRecalculated(eq(TENANT), anyLong(), anyString(),
|
||||
any(), any(), anyString(), any()))
|
||||
.thenReturn(1);
|
||||
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(
|
||||
regionEstimate("200", "8", "1600"), regionEstimate("200", "8", "1600"));
|
||||
@@ -855,7 +828,8 @@ class FbaPricingServiceTest {
|
||||
when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2);
|
||||
when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1);
|
||||
when(repository.supersedeCurrent(TENANT, 502L, "CALCULATED")).thenReturn(1);
|
||||
when(repository.markInboundContainerFeeRecalculated(eq(TENANT), eq(30002L), anyString(), anyString(), any()))
|
||||
when(repository.markInboundContainerFeeRecalculated(eq(TENANT), eq(30002L), anyString(),
|
||||
any(), any(), anyString(), any()))
|
||||
.thenReturn(1);
|
||||
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("180", "8", "1440"));
|
||||
when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn(
|
||||
@@ -939,7 +913,8 @@ class FbaPricingServiceTest {
|
||||
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(), any())).thenReturn(1);
|
||||
when(repository.markInboundCalculated(eq(TENANT), eq(INBOUND), any(), any(), any(), any(), any()))
|
||||
.thenReturn(1);
|
||||
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
|
||||
}
|
||||
|
||||
|
||||
+34
@@ -21,6 +21,7 @@ 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.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class InboundCartonLabelPrintServiceTest {
|
||||
@@ -118,6 +119,39 @@ class InboundCartonLabelPrintServiceTest {
|
||||
org.mockito.ArgumentMatchers.eq("request-batch"), org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformBatchCombinesLabelsAcrossCustomers() {
|
||||
when(labels.list(10001L, 70001L, null)).thenReturn(batch());
|
||||
when(labels.list(10002L, 70002L, null)).thenReturn(batch());
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_PDF);
|
||||
when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.eq("platform-batch"), org.mockito.ArgumentMatchers.any()))
|
||||
.thenReturn(new ResponseEntity<>(new byte[] {1, 2, 3}, headers,
|
||||
org.springframework.http.HttpStatus.OK));
|
||||
|
||||
ResponseEntity<byte[]> response = service.generateForPlatformInbounds(List.of(
|
||||
new InboundCartonLabels.PlatformBatchItem(10001L, 70001L),
|
||||
new InboundCartonLabels.PlatformBatchItem(10002L, 70002L)), "platform-batch");
|
||||
|
||||
assertThat(response.getHeaders().getFirst("X-Carton-Label-Order-Count")).isEqualTo("2");
|
||||
assertThat(response.getBody()).containsExactly(1, 2, 3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformBatchFailsWhenAnySelectedInboundCannotGenerateLabels() {
|
||||
when(labels.list(10001L, 70001L, null)).thenReturn(batch());
|
||||
when(labels.list(10002L, 70002L, null))
|
||||
.thenThrow(new BizException(ResultCode.NOT_FOUND, "箱唛入库单不存在"));
|
||||
|
||||
assertThatThrownBy(() -> service.generateForPlatformInbounds(List.of(
|
||||
new InboundCartonLabels.PlatformBatchItem(10001L, 70001L),
|
||||
new InboundCartonLabels.PlatformBatchItem(10002L, 70002L)), "platform-batch"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("箱唛入库单不存在");
|
||||
verifyNoInteractions(print);
|
||||
}
|
||||
|
||||
private InboundCartonLabels.Batch batch() {
|
||||
InboundCartonLabels.Label label = new InboundCartonLabels.Label(
|
||||
1L, 2L, 1, "音箱", 19, "PCHK2608040174#19-1", 19, 1,
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -233,6 +234,21 @@ class InboundCartonLabelServiceTest {
|
||||
.hasMessageContaining("订单服务暂不可用");
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertsOrderCargoCircuitBreakerFailureToActionableBusinessError() {
|
||||
InboundOrderEntity inbound = inbound();
|
||||
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
|
||||
when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(new ArrayList<>());
|
||||
when(repository.findByInbound(10001L, 70001L)).thenReturn(new ArrayList<>());
|
||||
when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L))
|
||||
.thenThrow(new NoFallbackAvailableException("order unavailable",
|
||||
new IllegalStateException("downstream failure")));
|
||||
|
||||
assertThatThrownBy(() -> service.getOrCreate(10001L, 70001L))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("订单服务暂不可用,无法生成唛头");
|
||||
}
|
||||
|
||||
@Test
|
||||
void repairsMissingHistoricalInboundFromOwnedOrderSnapshot() {
|
||||
var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand(
|
||||
|
||||
+112
-9
@@ -11,12 +11,14 @@ import com.baiyue.firstleg.dto.MeasurementBatchCommand;
|
||||
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||
import com.baiyue.firstleg.entity.InboundFeeEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
|
||||
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
|
||||
import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
|
||||
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
|
||||
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
|
||||
import com.baiyue.firstleg.repository.FbaPricingRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
@@ -57,6 +59,7 @@ import static org.mockito.Mockito.when;
|
||||
class InboundMeasurementServiceTest {
|
||||
@MockBean private InboundMeasurementRepository repository;
|
||||
@MockBean private MeasurementBatchService batchService;
|
||||
@MockBean private FbaPricingRepository pricingRepository;
|
||||
@MockBean private FbaMasterDataService fbaMasterDataService;
|
||||
@MockBean private OrderCargoClient orderCargoClient;
|
||||
@MockBean private MeasurementImageClient imageClient;
|
||||
@@ -90,6 +93,85 @@ class InboundMeasurementServiceTest {
|
||||
verify(repository, never()).listWeights(anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatesSubmittedCalculationStrategyWithoutReplacingMeasurementBatch() {
|
||||
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(30L);
|
||||
measurement.setTenantId(1L);
|
||||
measurement.setInboundId(10L);
|
||||
measurement.setStatus("SUBMITTED");
|
||||
measurement.setVersion(5);
|
||||
measurement.setTotalVolumeCbm(new BigDecimal("2.5000"));
|
||||
measurement.setVolumeWeightKg(new BigDecimal("417.500"));
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setId(31L);
|
||||
item.setTenantId(1L);
|
||||
item.setMeasurementId(30L);
|
||||
when(repository.lockInbound(1L, 10L)).thenReturn(inbound);
|
||||
when(repository.lockMeasurement(1L, 10L, 30L)).thenReturn(measurement);
|
||||
when(repository.listItems(1L, 30L)).thenReturn(List.of(item));
|
||||
when(repository.updateMeasurement(measurement)).thenReturn(true);
|
||||
when(repository.updateInbound(inbound)).thenReturn(true);
|
||||
|
||||
var response = service.updateCalculationStrategy(1L, 10L,
|
||||
new InboundMeasurementRequests.UpdateCalculationStrategyRequest(30L, 5, "ADD", null));
|
||||
|
||||
assertThat(response.status()).isEqualTo("SUBMITTED");
|
||||
verify(batchService).recalculateStrategy(1L, measurement, List.of(item), "ADD", null);
|
||||
verify(repository, never()).replaceItems(anyLong(), anyLong(), any());
|
||||
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("2.5000");
|
||||
}
|
||||
|
||||
@Test
|
||||
void locksCalculationStrategyOnlyAfterCustomerConfirmationAndLoadingCompletion() {
|
||||
InboundOrderEntity inbound = inbound("LOADED");
|
||||
InboundFeeEntity fee = new InboundFeeEntity();
|
||||
fee.setCustomerConfirmedAt(LocalDateTime.now());
|
||||
when(repository.lockInbound(1L, 10L)).thenReturn(inbound);
|
||||
when(pricingRepository.findLatestFeeForUpdate(1L, 10L)).thenReturn(fee);
|
||||
|
||||
assertThatThrownBy(() -> service.updateCalculationStrategy(1L, 10L,
|
||||
new InboundMeasurementRequests.UpdateCalculationStrategyRequest(30L, 5, "ACTUAL", null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("客户已确认且该票已装柜完成");
|
||||
|
||||
verify(repository, never()).lockMeasurement(anyLong(), anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatesSubmittedWarehouseOuterDimensionsWithoutReplacingMeasurementBatch() {
|
||||
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(30L);
|
||||
measurement.setTenantId(1L);
|
||||
measurement.setInboundId(10L);
|
||||
measurement.setStatus("SUBMITTED");
|
||||
measurement.setVersion(5);
|
||||
measurement.setTotalVolumeCbm(new BigDecimal("1.8000"));
|
||||
measurement.setVolumeWeightKg(new BigDecimal("300.600"));
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setId(31L);
|
||||
item.setTenantId(1L);
|
||||
item.setMeasurementId(30L);
|
||||
when(repository.lockInbound(1L, 10L)).thenReturn(inbound);
|
||||
when(repository.lockMeasurement(1L, 10L, 30L)).thenReturn(measurement);
|
||||
when(repository.listItems(1L, 30L)).thenReturn(List.of(item));
|
||||
when(repository.updateMeasurement(measurement)).thenReturn(true);
|
||||
when(repository.updateInbound(inbound)).thenReturn(true);
|
||||
|
||||
var response = service.updateOuterDimensions(1L, 10L, 31L,
|
||||
new InboundMeasurementRequests.UpdateOuterDimensionsRequest(
|
||||
30L, 5, new BigDecimal("30"), new BigDecimal("20"), new BigDecimal("20")));
|
||||
|
||||
assertThat(response.status()).isEqualTo("SUBMITTED");
|
||||
assertThat(response.itemId()).isEqualTo(31L);
|
||||
verify(batchService).recalculateOuterDimensions(1L, measurement, List.of(item), 31L,
|
||||
new BigDecimal("30"), new BigDecimal("20"), new BigDecimal("20"));
|
||||
verify(repository, never()).replaceItems(anyLong(), anyLong(), any());
|
||||
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("1.8000");
|
||||
}
|
||||
|
||||
@Test
|
||||
void correctingWeightKeepsOcrOriginalAndWritesImmutableRevision() {
|
||||
InboundOrderEntity inbound = inbound("MEASURING");
|
||||
@@ -1089,7 +1171,7 @@ class InboundMeasurementServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitDoesNotBlockOnUnconfirmedWeight() {
|
||||
void submitRejectsWhenNoWeightImageHasBeenConfirmed() {
|
||||
InboundOrderEntity inbound = inbound("MEASURING");
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(30L);
|
||||
@@ -1103,16 +1185,37 @@ class InboundMeasurementServiceTest {
|
||||
when(repository.listItems(1L, 30L)).thenReturn(List.of(item));
|
||||
when(repository.listWeights(1L, 10L)).thenReturn(List.of(
|
||||
weight(20L, "20", "KG", "PENDING_CONFIRM", 0)));
|
||||
when(repository.updateMeasurement(measurement)).thenReturn(true);
|
||||
when(repository.updateInbound(inbound)).thenReturn(true);
|
||||
assertThatThrownBy(() -> service.submit(1L, 10L, 9L,
|
||||
new InboundMeasurementRequests.SubmitRequest(30L, 0)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("至少上传并确认一张称重图片");
|
||||
|
||||
InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L,
|
||||
new InboundMeasurementRequests.SubmitRequest(30L, 0));
|
||||
verify(repository, never()).updateMeasurement(any());
|
||||
verify(repository, never()).updateInbound(any());
|
||||
}
|
||||
|
||||
assertThat(result.processStatus()).isEqualTo("MEASURE_PENDING");
|
||||
assertThat(inbound.getActualGrossWeightKg()).isNull();
|
||||
verify(repository).updateMeasurement(measurement);
|
||||
verify(repository).updateInbound(inbound);
|
||||
@Test
|
||||
void submitRejectsWhenThereIsNoWeightImage() {
|
||||
InboundOrderEntity inbound = inbound("MEASURING");
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(30L);
|
||||
measurement.setStatus("DRAFT");
|
||||
measurement.setVersion(0);
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setPackageCount(1);
|
||||
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());
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -521,16 +521,22 @@ class LoadingPickServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void taskDetailRejectsWarehouseThatDoesNotOwnTheContainer() {
|
||||
void taskDetailDoesNotRecheckWarehouseOwnership() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
when(repository.task(20L)).thenReturn(task);
|
||||
when(repository.items(20L)).thenReturn(List.of(item()));
|
||||
when(repository.items(20L, 999L)).thenReturn(List.of(item()));
|
||||
when(repository.cartonLabels(20L)).thenReturn(List.of());
|
||||
when(repository.inboundOwners(20L)).thenReturn(List.of(
|
||||
new LoadingPickItemMapper.InboundOwner(101L, 50L)));
|
||||
when(palletAllocations.list(101L, 50L)).thenReturn(List.of(allocation(50L, 2, 202L)));
|
||||
|
||||
assertThatThrownBy(() -> service.task(20L, 999L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不属于当前仓库");
|
||||
var result = service.task(20L, 999L);
|
||||
|
||||
verify(repository, never()).items(20L, 999L);
|
||||
verify(repository, never()).cartonLabels(20L);
|
||||
verify(completionCommands, never()).latest(20L);
|
||||
assertThat(result.id()).isEqualTo(20L);
|
||||
assertThat(result.items()).hasSize(1);
|
||||
assertThat(result.items().get(0).inventoryResources()).singleElement()
|
||||
.extracting("availablePackageCount").isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1412,6 +1418,40 @@ class LoadingPickServiceTest {
|
||||
verify(settlementService, never()).settle(20L, 9L, "operator");
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchUsesConfirmedInboundInventoryWhenContainerLoadsAcrossWarehouses() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
LoadingPickItemEntity item = item();
|
||||
item.setPlannedPackageCount(250);
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||
when(repository.task(20L)).thenReturn(task);
|
||||
when(repository.assignment(20L)).thenReturn(assignment());
|
||||
when(repository.items(20L)).thenReturn(List.of(item));
|
||||
when(repository.items(20L, 101L)).thenReturn(List.of(item));
|
||||
when(repository.inboundOwners(20L)).thenReturn(List.of(
|
||||
new LoadingPickItemMapper.InboundOwner(1L, 50L)));
|
||||
when(palletAllocations.list(1L, 50L)).thenReturn(List.of(allocation(50L, 250, 202L)));
|
||||
when(repository.pick(30L, 250, 0, 101L)).thenReturn(1);
|
||||
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
|
||||
when(repository.touchTask(20L, 0)).thenReturn(1);
|
||||
|
||||
var snapshot = service.inventorySnapshot(20L, 101L);
|
||||
assertThat(snapshot.warehouseId()).isEqualTo(101L);
|
||||
assertThat(snapshot.inbounds()).singleElement().satisfies(inbound -> {
|
||||
assertThat(inbound.availablePackageCount()).isEqualTo(250);
|
||||
assertThat(inbound.resources()).singleElement()
|
||||
.extracting("availablePackageCount")
|
||||
.isEqualTo(250);
|
||||
});
|
||||
|
||||
service.batch(20L, "batch-cross-warehouse", new BatchRequest(
|
||||
101L, 10L, 99L, 0, snapshot.snapshotToken(), List.of(
|
||||
new BatchItemRequest(30L, "PICK", 250, null, 0, null, null))), 9L, "operator");
|
||||
|
||||
verify(repository).pick(30L, 250, 0, 101L);
|
||||
verify(repository).touchTask(20L, 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBatchTaskVersionConflictBeforeWriting() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
@@ -1561,6 +1601,11 @@ class LoadingPickServiceTest {
|
||||
}
|
||||
|
||||
private static InboundPalletAllocationEntity allocation(long inboundId, int packageCount) {
|
||||
return allocation(inboundId, packageCount, 101L);
|
||||
}
|
||||
|
||||
private static InboundPalletAllocationEntity allocation(long inboundId, int packageCount,
|
||||
long warehouseId) {
|
||||
InboundPalletAllocationEntity allocation = new InboundPalletAllocationEntity();
|
||||
allocation.setId(72L);
|
||||
allocation.setTenantId(1L);
|
||||
@@ -1568,7 +1613,7 @@ class LoadingPickServiceTest {
|
||||
allocation.setBlockId(71L);
|
||||
allocation.setSlotNo(1);
|
||||
allocation.setAllocationNo(1);
|
||||
allocation.setWarehouseId(101L);
|
||||
allocation.setWarehouseId(warehouseId);
|
||||
allocation.setPalletId(73L);
|
||||
allocation.setLocationId(74L);
|
||||
allocation.setLocationCode("A-01");
|
||||
|
||||
@@ -26,6 +26,7 @@ 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.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -97,6 +98,81 @@ class MeasurementBatchServiceTest {
|
||||
assertThat(items.getValue().get(0).getInboundDetailId()).isEqualTo(80001L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recalculatesSubmittedItemsInPlaceWithoutChangingTheirIdentityOrStatus() {
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(100L);
|
||||
measurement.setTenantId(1L);
|
||||
measurement.setInboundId(10L);
|
||||
measurement.setSourceType("MANUAL");
|
||||
measurement.setStatus("SUBMITTED");
|
||||
measurement.setVersion(4);
|
||||
measurement.setCreatedBy(9L);
|
||||
InboundMeasurementItemEntity existing = new InboundMeasurementItemEntity();
|
||||
existing.setId(200L);
|
||||
existing.setTenantId(1L);
|
||||
existing.setMeasurementId(100L);
|
||||
existing.setInboundDetailId(80001L);
|
||||
existing.setBoxCode("RK-001#1");
|
||||
existing.setItemNo("001");
|
||||
existing.setBoxSpecification("40x30x20cm");
|
||||
existing.setPackageCount(2);
|
||||
existing.setLengthCm(new BigDecimal("40"));
|
||||
existing.setWidthCm(new BigDecimal("30"));
|
||||
existing.setHeightCm(new BigDecimal("20"));
|
||||
when(repository.updateItem(existing)).thenReturn(true);
|
||||
|
||||
service.recalculateStrategy(1L, measurement, List.of(existing), "ACTUAL", null);
|
||||
|
||||
assertThat(existing.getId()).isEqualTo(200L);
|
||||
assertThat(existing.getBoxCode()).isEqualTo("RK-001#1");
|
||||
assertThat(existing.getOuterLengthCm()).isEqualByComparingTo("40.000");
|
||||
assertThat(measurement.getStatus()).isEqualTo("SUBMITTED");
|
||||
assertThat(measurement.getTotalVolumeCbm()).isEqualByComparingTo("0.0480");
|
||||
verify(repository).updateItem(existing);
|
||||
verify(repository, never()).replaceItems(anyLong(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void recalculatesOneSubmittedWarehouseOuterDimensionRowInPlace() {
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(100L);
|
||||
measurement.setTenantId(1L);
|
||||
measurement.setInboundId(10L);
|
||||
measurement.setSourceType("MANUAL");
|
||||
measurement.setStatus("SUBMITTED");
|
||||
measurement.setVersion(4);
|
||||
measurement.setCreatedBy(9L);
|
||||
InboundMeasurementItemEntity existing = new InboundMeasurementItemEntity();
|
||||
existing.setId(200L);
|
||||
existing.setTenantId(1L);
|
||||
existing.setMeasurementId(100L);
|
||||
existing.setInboundDetailId(80001L);
|
||||
existing.setBoxCode("RK-001#1");
|
||||
existing.setItemNo("001");
|
||||
existing.setBoxSpecification("40x30x20cm");
|
||||
existing.setPackageCount(2);
|
||||
existing.setLengthCm(new BigDecimal("40"));
|
||||
existing.setWidthCm(new BigDecimal("30"));
|
||||
existing.setHeightCm(new BigDecimal("20"));
|
||||
when(repository.updateItem(existing)).thenReturn(true);
|
||||
|
||||
service.recalculateOuterDimensions(1L, measurement, List.of(existing), 200L,
|
||||
new BigDecimal("30"), new BigDecimal("20"), new BigDecimal("20"));
|
||||
|
||||
assertThat(existing.getId()).isEqualTo(200L);
|
||||
assertThat(existing.getBoxCode()).isEqualTo("RK-001#1");
|
||||
assertThat(existing.getManualOuterLengthCm()).isEqualByComparingTo("30");
|
||||
assertThat(existing.getManualOuterWidthCm()).isEqualByComparingTo("20");
|
||||
assertThat(existing.getManualOuterHeightCm()).isEqualByComparingTo("20");
|
||||
assertThat(existing.getOuterLengthCm()).isEqualByComparingTo("30.000");
|
||||
assertThat(existing.getUnitVolumeCbm()).isEqualByComparingTo("0.0120");
|
||||
assertThat(measurement.getStatus()).isEqualTo("SUBMITTED");
|
||||
assertThat(measurement.getTotalVolumeCbm()).isEqualByComparingTo("0.0240");
|
||||
verify(repository).updateItem(existing);
|
||||
verify(repository, never()).replaceItems(anyLong(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void automaticallyAppliesCustomerDefaultRuleToActualMeasurement() {
|
||||
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
|
||||
|
||||
+16
-1
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
import java.nio.file.Files;
|
||||
@@ -50,10 +51,24 @@ class TerminalDrayageOpenApiContractTest {
|
||||
"platform:firstleg:terminal-drayage:pickup:confirm",
|
||||
"platform:firstleg:terminal-drayage:empty-return:confirm",
|
||||
"expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt",
|
||||
"emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays");
|
||||
"emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays",
|
||||
"船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情",
|
||||
"允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件");
|
||||
assertThat(yaml).contains("description: 卸柜完成时间加72小时");
|
||||
}
|
||||
|
||||
@Test
|
||||
void terminalDrayageContextsUseConfirmedOutboundAsTheOverseasBoundary() throws Exception {
|
||||
var mapper = com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class;
|
||||
for (String methodName : java.util.List.of("context", "lockContext", "contexts")) {
|
||||
var method = java.util.Arrays.stream(mapper.getDeclaredMethods())
|
||||
.filter(value -> methodName.equals(value.getName())).findFirst().orElseThrow();
|
||||
String sql = String.join("\n", method.getAnnotation(Select.class).value());
|
||||
assertThat(sql).contains("FROM fl_outbound_dispatch", "d.confirmed_at IS NOT NULL",
|
||||
"d.reversed_at IS NULL", "d.status='WAITING_FOR_LOADING'");
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertPermission(String methodName, String permission) {
|
||||
var method = java.util.Arrays.stream(PlatformTerminalDrayageController.class.getDeclaredMethods())
|
||||
.filter(value -> methodName.equals(value.getName())).findFirst().orElseThrow();
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ class TerminalDrayageSqlContractTest {
|
||||
"UNIQUE KEY uk_terminal_drayage_operation(container_booking_id,idempotency_key)",
|
||||
"服务端按卸柜完成时间加72小时计算的预计还柜时间");
|
||||
assertThat(sql).doesNotContain("ON DELETE CASCADE");
|
||||
assertThat(mapper).contains("WHERE b.id=#{containerBookingId} FOR UPDATE",
|
||||
assertThat(mapper).contains("WHERE b.id=#{containerBookingId}", "FOR UPDATE",
|
||||
"WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}")
|
||||
.doesNotContain("fl_carrier_schedule_query", "fl_carrier_schedule_node_fact");
|
||||
}
|
||||
|
||||
+31
-2
@@ -84,6 +84,35 @@ class ContainerWorkbenchServiceTest {
|
||||
verifyNoInteractions(carrierSchedules);
|
||||
}
|
||||
|
||||
@Test
|
||||
void overseasDetailKeepsNewlyDepartedContainerBeforeCarrierTrackingExists() {
|
||||
ContainerBookingEntity container = container();
|
||||
container.setOutboundConfirmedAt(LocalDateTime.of(2026, 9, 10, 10, 50));
|
||||
Set<String> permissions = Set.of("platform:firstleg:terminal-drayage:list",
|
||||
"platform:firstleg:terminal-drayage:document:edit");
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(repository.departed(11L)).thenReturn(true);
|
||||
var waiting = waitingDrayage();
|
||||
when(terminalDrayages.views(List.of(11L))).thenReturn(List.of(waiting));
|
||||
when(terminalDrayages.authorize(List.of(waiting), permissions)).thenReturn(List.of(waiting));
|
||||
|
||||
var result = service.overseasDetail(11L, permissions);
|
||||
|
||||
assertThat(result.workbenchStage()).isEqualTo("DEPARTED_WAREHOUSE");
|
||||
assertThat(result.actualFacts()).isNull();
|
||||
assertThat(result.terminalDrayage()).isEqualTo(waiting);
|
||||
assertThat(result.allowedActions()).containsExactly("MANAGE_DESTINATION_DOCUMENTS");
|
||||
verify(terminalDrayages, never()).detail(11L);
|
||||
verifyNoInteractions(carrierSchedules);
|
||||
}
|
||||
|
||||
private static com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View waitingDrayage() {
|
||||
return new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View(
|
||||
11L, "MSCU1234567", 21L, "WAITING_FOR_ARRIVAL", 0, null, null, null,
|
||||
null, null, null, null, new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.Warning(
|
||||
"NOT_APPLICABLE", null, 0, 0), List.of(), null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void realOverseasListAndDetailProjectBothEmptyReturnActions() throws Exception {
|
||||
verifyRealEmptyReturnProjection(true, true);
|
||||
@@ -139,8 +168,8 @@ class ContainerWorkbenchServiceTest {
|
||||
var serialized = json.readTree(json.writeValueAsString(com.baiyue.common.core.api.ApiResponse.success(page)));
|
||||
assertThat(serialized.at("/data/records/0/allowedActions")).isEqualTo(json.valueToTree(expected));
|
||||
assertThat(serialized.at("/data/records/0/terminalDrayage/allowedActions")).isEqualTo(json.valueToTree(expected));
|
||||
verify(mapper).findAll(List.of(11L));
|
||||
verify(mapper).contexts(List.of(11L));
|
||||
verify(mapper, times(2)).findAll(List.of(11L));
|
||||
verify(mapper, times(2)).contexts(List.of(11L));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+24
-11
@@ -2,7 +2,7 @@ package com.baiyue.firstleg.terminaldrayage.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.WarehouseSupplierClient;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
||||
@@ -125,8 +125,8 @@ class TerminalDrayageServiceTest {
|
||||
|
||||
assertThat(result.status()).isEqualTo("PICKED_UP");
|
||||
assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0));
|
||||
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class);
|
||||
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
|
||||
ArgumentCaptor<ContainerManualNodeRequest> node = ArgumentCaptor.forClass(ContainerManualNodeRequest.class);
|
||||
verify(carrierSchedules).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
|
||||
assertThat(node.getValue().standardNode()).isEqualTo("PICKED_UP");
|
||||
verify(mapper, times(1)).updateAggregate(any(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
@@ -144,7 +144,7 @@ class TerminalDrayageServiceTest {
|
||||
new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 10, 0), null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("海外卸柜收货完成后才能确认还柜");
|
||||
verify(carrierSchedules, never()).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
|
||||
verify(carrierSchedules, never()).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -212,7 +212,7 @@ class TerminalDrayageServiceTest {
|
||||
assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN");
|
||||
assertThat(service.recordEmptyReturn(11L, 7L, "admin", "return-new", request)).isEqualTo(result);
|
||||
verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class));
|
||||
verify(carrierSchedules, times(1)).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
|
||||
verify(carrierSchedules, times(1)).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -222,7 +222,7 @@ class TerminalDrayageServiceTest {
|
||||
new ActualTimeRequest(0, LocalDateTime.of(2026, 9, 2, 23, 0), null)))
|
||||
.isInstanceOf(BizException.class).hasMessage("实际还柜时间不能早于提柜或卸柜完成时间");
|
||||
verify(mapper, never()).insert(any(TerminalDrayageEntity.class));
|
||||
verify(carrierSchedules, never()).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
|
||||
verify(carrierSchedules, never()).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -243,13 +243,26 @@ class TerminalDrayageServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkViewOmitsContainerWhenActualFactPortHasNoTrackingRecord() {
|
||||
void readsDepartedContainerBeforeCarrierTrackingRecordExists() {
|
||||
when(mapper.contexts(List.of(11L))).thenReturn(List.of(
|
||||
new Context(11L, "MSCU1234567", 21L, "ACTIVE", null)));
|
||||
when(mapper.findAll(List.of(11L))).thenReturn(List.of());
|
||||
when(mapper.context(11L)).thenReturn(
|
||||
new Context(11L, "MSCU1234567", 21L, "ACTIVE", null));
|
||||
when(mapper.find(11L)).thenReturn(null);
|
||||
|
||||
assertThat(service.views(List.of(11L))).isEmpty();
|
||||
verify(carrierSchedules).containerActualFacts(List.of(11L));
|
||||
var bulk = service.views(List.of(11L));
|
||||
var detail = service.detail(11L);
|
||||
|
||||
assertThat(bulk).singleElement().satisfies(view -> {
|
||||
assertThat(view.status()).isEqualTo("WAITING_FOR_ARRIVAL");
|
||||
assertThat(view.actualFacts()).isNull();
|
||||
assertThat(view.allowedActions()).isEmpty();
|
||||
});
|
||||
assertThat(detail.status()).isEqualTo("WAITING_FOR_ARRIVAL");
|
||||
assertThat(detail.actualFacts()).isNull();
|
||||
assertThat(detail.allowedActions()).isEmpty();
|
||||
verify(carrierSchedules, times(2)).containerActualFacts(List.of(11L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -338,8 +351,8 @@ class TerminalDrayageServiceTest {
|
||||
assertThat(result.status()).isEqualTo("EMPTY_RETURNED");
|
||||
assertThat(result.estimatedEmptyReturnAt()).isEqualTo(unloadedAt.plusHours(72));
|
||||
assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED");
|
||||
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class);
|
||||
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
|
||||
ArgumentCaptor<ContainerManualNodeRequest> node = ArgumentCaptor.forClass(ContainerManualNodeRequest.class);
|
||||
verify(carrierSchedules).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
|
||||
assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED");
|
||||
assertThat(node.getValue().reason()).isEqualTo("已还柜");
|
||||
assertThat(node.getValue().actualAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 9, 0));
|
||||
|
||||
@@ -77,7 +77,7 @@ spring:
|
||||
- id: firstleg-service
|
||||
uri: lb://firstleg-service
|
||||
predicates:
|
||||
- Path=/api/merchant/v1/first-leg/**,/api/merchant/v1/customs/**,/api/platform/v1/first-leg/**,/api/platform/v1/customs/**,/api/platform/v1/port-schedules/**,/api/platform/v1/carrier-schedule-queries/**,/api/platform/v1/containers/**,/api/platform/v1/terminal-drayages/**,/api/platform/v1/customers/{customerId}/container-transit-summary,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/v1/loading-plans/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/**
|
||||
- Path=/api/merchant/v1/first-leg/**,/api/merchant/v1/customs/**,/api/platform/v1/first-leg/**,/api/platform/v1/customs/**,/api/platform/v1/port-schedules/**,/api/platform/v1/carrier-schedule-queries/**,/api/platform/v1/container-shipping-nodes/**,/api/platform/v1/containers/**,/api/platform/v1/terminal-drayages/**,/api/platform/v1/customers/{customerId}/container-transit-summary,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/v1/loading-plans/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/**
|
||||
- id: finance-service
|
||||
uri: lb://finance-service
|
||||
predicates:
|
||||
|
||||
+1
@@ -39,6 +39,7 @@ class FirstlegContainerRouteContractTest {
|
||||
.contains("/api/merchant/v1/customs/**")
|
||||
.contains("/api/platform/v1/port-schedules/**")
|
||||
.contains("/api/platform/v1/carrier-schedule-queries/**")
|
||||
.contains("/api/platform/v1/container-shipping-nodes/**")
|
||||
.contains("/api/platform/v1/first-leg/**");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
| 目标 | 入口 |
|
||||
|---|---|
|
||||
| 客户资料/审核与企业联系信息 | `controller/PlatformTenantController.java`、`service/TenantService.java`;公司联系电话/邮箱属于企业资料,不作为登录标识且不要求跨企业唯一;审核必填资质仅营业执照与统一社会信用代码,法人身份证选填 |
|
||||
| 客户默认尺寸初始化、重试与补偿 | `service/CustomerDimensionRuleInitializationService.java`、`CustomerDimensionInitializationRelay.java`、`repository/CustomerDimensionInitializationRepository.java`、`client/CustomerDimensionRuleClient.java`、`model/CustomerDimensionInitializationTask.java`;持久任务异步调用 Firstleg `/internal/firstleg/v1/customer-dimension-configs/defaults` |
|
||||
| 客户默认箱规与权重系数初始化、重试及补偿 | `service/CustomerDimensionRuleInitializationService.java`、`CustomerDimensionInitializationRelay.java`、`repository/CustomerDimensionInitializationRepository.java`、`client/CustomerDimensionRuleClient.java`、`model/CustomerDimensionInitializationTask.java`;持久任务异步调用 Firstleg `/internal/firstleg/v1/customer-dimension-configs/defaults`,尺寸配置和默认权重系数规则均返回成功后才标记 `READY` |
|
||||
| 商户成员、登录手机号与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java`、`client/AuthAccountClient.java`;成员手机号是可选登录标识,变更时同步 Auth、撤销该账号全部会话并审计,不与公司联系电话联动 |
|
||||
| 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 必须且只能绑定一个 `overseasWarehouseId` |
|
||||
| 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` |
|
||||
|
||||
@@ -8,7 +8,7 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
/** Firstleg internal contract for atomically provisioning four default box-size dimension records. */
|
||||
/** Firstleg internal contract for atomically provisioning box-size and weight-coefficient defaults. */
|
||||
@FeignClient(name = "firstleg-service", contextId = "customerDimensionRuleClient",
|
||||
url = "${baiyue.clients.firstleg.url:}",
|
||||
path = "/internal/firstleg/v1/customer-dimension-configs")
|
||||
@@ -24,7 +24,8 @@ public interface CustomerDimensionRuleClient {
|
||||
@RequestParam long tenantId,
|
||||
@RequestParam String initializationKey);
|
||||
|
||||
record InitializationRequest(long tenantId, long operatorId, String initializationKey) { }
|
||||
/** configId is the first record ID retained only for contract compatibility. */
|
||||
record InitializationResponse(Long configId, boolean createdByThisInitialization) { }
|
||||
record InitializationRequest(long tenantId, long operatorId, String initializationKey,
|
||||
String customerName) { }
|
||||
record InitializationResponse(Long configId, Long chargeRuleId,
|
||||
boolean createdByThisInitialization) { }
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ package com.baiyue.iam.model;
|
||||
public record CustomerDimensionInitializationTask(
|
||||
long tenantId,
|
||||
long operatorId,
|
||||
String customerName,
|
||||
String initializationKey,
|
||||
String action,
|
||||
String status,
|
||||
int attemptCount,
|
||||
int taskVersion) {
|
||||
}
|
||||
|
||||
|
||||
+7
-4
@@ -73,11 +73,14 @@ public class CustomerDimensionInitializationRepository {
|
||||
+ "WHERE tenant_id=? AND status='PENDING' AND next_retry_at<=?",
|
||||
workerId, now.plusMinutes(2), now, tenantId, now);
|
||||
if (claimed != 1) return null;
|
||||
return jdbc.queryForObject("SELECT tenant_id,operator_id,initialization_key,action,status,"
|
||||
+ "attempt_count,task_version FROM iam_customer_dimension_initialization_task "
|
||||
+ "WHERE tenant_id=? AND status='SENDING' AND lease_owner=?",
|
||||
return jdbc.queryForObject("SELECT task.tenant_id,task.operator_id,tenant.tenant_name,"
|
||||
+ "task.initialization_key,task.action,task.status,task.attempt_count,task.task_version "
|
||||
+ "FROM iam_customer_dimension_initialization_task task "
|
||||
+ "JOIN iam_tenant tenant ON tenant.id=task.tenant_id "
|
||||
+ "WHERE task.tenant_id=? AND task.status='SENDING' AND task.lease_owner=?",
|
||||
(rs, row) -> new CustomerDimensionInitializationTask(
|
||||
rs.getLong("tenant_id"), rs.getLong("operator_id"), rs.getString("initialization_key"),
|
||||
rs.getLong("tenant_id"), rs.getLong("operator_id"), rs.getString("tenant_name"),
|
||||
rs.getString("initialization_key"),
|
||||
rs.getString("action"), rs.getString("status"), rs.getInt("attempt_count"),
|
||||
rs.getInt("task_version")), tenantId, workerId);
|
||||
}
|
||||
|
||||
@@ -43,10 +43,11 @@ public class CustomerDimensionInitializationRelay {
|
||||
}
|
||||
ApiResponse<CustomerDimensionRuleClient.InitializationResponse> response = client.initialize(
|
||||
internalToken, new CustomerDimensionRuleClient.InitializationRequest(
|
||||
task.tenantId(), task.operatorId(), task.initializationKey()));
|
||||
task.tenantId(), task.operatorId(), task.initializationKey(), task.customerName()));
|
||||
requireSuccess(response, true);
|
||||
if (response.data() == null || response.data().configId() == null) {
|
||||
throw new DeliveryFailure("S0503", "Firstleg 未返回默认尺寸配置ID", false);
|
||||
if (response.data() == null || response.data().configId() == null
|
||||
|| response.data().chargeRuleId() == null) {
|
||||
throw new DeliveryFailure("S0503", "Firstleg 未完整返回默认尺寸和权重系数规则ID", false);
|
||||
}
|
||||
repository.markReady(task, workerId, response.data().configId());
|
||||
} catch (Exception failure) {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- 订单申报与运营预估权限模块来源。
|
||||
-- 只操作 IAM 权限资源和角色授权,不创建账号、客户或业务数据。
|
||||
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
|
||||
(5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit',
|
||||
'修改订单申报与运营预估', NULL, NULL, 130, 'ACTIVE', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id),
|
||||
permission_type = VALUES(permission_type), app_code = VALUES(app_code),
|
||||
sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0;
|
||||
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT role_id, 5102027, 0
|
||||
FROM (SELECT 4000001 AS role_id UNION ALL SELECT 4000003 AS role_id) roles
|
||||
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
|
||||
@@ -130,18 +130,19 @@ VALUES
|
||||
(5102019, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:create', '平台代客创建订单', NULL, NULL, 90, 'ACTIVE', 0),
|
||||
(5102024, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:edit', '平台编辑订单草稿', NULL, NULL, 100, 'ACTIVE', 0),
|
||||
(5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 0),
|
||||
(5102026, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:export', '导出订单列表', NULL, NULL, 120, 'ACTIVE', 0)
|
||||
(5102026, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:export', '导出订单列表', NULL, NULL, 120, 'ACTIVE', 0),
|
||||
(5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit', '修改订单申报与运营预估', NULL, NULL, 130, 'ACTIVE', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), 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 (5102019, 5102024, 5102025, 5102026)
|
||||
WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)
|
||||
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 (5102019, 5102024, 5102025, 5102026)
|
||||
WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)
|
||||
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
|
||||
|
||||
INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class CustomerDimensionRuleClientContractTest {
|
||||
@Test
|
||||
void usesOneAtomicCallForTheFourDefaultBoxDimensionRecords() throws Exception {
|
||||
void usesOneAtomicCallForBoxDimensionsAndWeightCoefficientRule() throws Exception {
|
||||
FeignClient feign = CustomerDimensionRuleClient.class.getAnnotation(FeignClient.class);
|
||||
PostMapping initialize = CustomerDimensionRuleClient.class
|
||||
.getMethod("initialize", String.class,
|
||||
@@ -25,6 +25,6 @@ class CustomerDimensionRuleClientContractTest {
|
||||
assertThat(compensate.value()).containsExactly("/defaults");
|
||||
assertThat(CustomerDimensionRuleClient.InitializationResponse.class.getRecordComponents())
|
||||
.extracting(java.lang.reflect.RecordComponent::getName)
|
||||
.containsExactly("configId", "createdByThisInitialization");
|
||||
.containsExactly("configId", "chargeRuleId", "createdByThisInitialization");
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.baiyue.iam.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PermissionInitializationSqlContractTest {
|
||||
|
||||
@Test
|
||||
void keepsSharedPermissionEntryFocusedAndAlignedWithFullBaseline() throws Exception {
|
||||
String module = new ClassPathResource("db/order-declaration-planning-permission.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
String permissionOnly = Files.readString(resolve("deploy/sql/权限点初始化-MySQL8.sql"),
|
||||
StandardCharsets.UTF_8);
|
||||
String unified = Files.readString(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"),
|
||||
StandardCharsets.UTF_8);
|
||||
|
||||
for (String source : new String[] {module, permissionOnly, unified}) {
|
||||
assertThat(source)
|
||||
.contains("5102027")
|
||||
.contains("platform:order:declaration:edit")
|
||||
.contains("4000001")
|
||||
.contains("4000003");
|
||||
}
|
||||
assertThat(permissionOnly)
|
||||
.contains("START TRANSACTION")
|
||||
.contains("permission_initialization_status")
|
||||
.contains("SIGNAL SQLSTATE '45000'")
|
||||
.contains("apply_permission_points")
|
||||
.doesNotContain("apply_domestic_permission_points", "CREATE TABLE", "ALTER TABLE",
|
||||
"INSERT INTO iam_tenant", "INSERT INTO iam_user", "INSERT INTO iam_membership",
|
||||
"INSERT INTO auth_account");
|
||||
}
|
||||
|
||||
private static Path resolve(String relative) {
|
||||
Path direct = Path.of(relative);
|
||||
return Files.exists(direct) ? direct : Path.of("..").resolve(relative);
|
||||
}
|
||||
}
|
||||
+20
-2
@@ -43,15 +43,33 @@ class CustomerDimensionInitializationRelayTest {
|
||||
CustomerDimensionInitializationTask task = task("INITIALIZE", 0);
|
||||
when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task);
|
||||
when(client.initialize(anyString(), any())).thenReturn(ApiResponse.success(
|
||||
new CustomerDimensionRuleClient.InitializationResponse(91L, true)));
|
||||
new CustomerDimensionRuleClient.InitializationResponse(91L, 191L, true)));
|
||||
|
||||
relay.deliver(10001L);
|
||||
|
||||
ArgumentCaptor<CustomerDimensionRuleClient.InitializationRequest> request =
|
||||
ArgumentCaptor.forClass(CustomerDimensionRuleClient.InitializationRequest.class);
|
||||
verify(client).initialize(anyString(), request.capture());
|
||||
assertTrue("柏悦物流".equals(request.getValue().customerName()));
|
||||
verify(repository).markReady(task, workerId(), 91L);
|
||||
verify(repository, never()).recordFailure(any(), anyString(), anyInt(), any(), anyString(),
|
||||
anyString(), any(Boolean.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingWeightCoefficientRuleKeepsInitializationFromBecomingReady() {
|
||||
CustomerDimensionInitializationTask task = task("INITIALIZE", 0);
|
||||
when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task);
|
||||
when(client.initialize(anyString(), any())).thenReturn(ApiResponse.success(
|
||||
new CustomerDimensionRuleClient.InitializationResponse(91L, null, true)));
|
||||
|
||||
relay.deliver(10001L);
|
||||
|
||||
verify(repository, never()).markReady(any(), anyString(), any());
|
||||
verify(repository).recordFailure(any(), anyString(), anyInt(), any(), anyString(),
|
||||
anyString(), any(Boolean.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void transientFailureStaysPendingWithDiagnosticAndBackoff() {
|
||||
CustomerDimensionInitializationTask task = task("INITIALIZE", 0);
|
||||
@@ -95,7 +113,7 @@ class CustomerDimensionInitializationRelayTest {
|
||||
}
|
||||
|
||||
private CustomerDimensionInitializationTask task(String action, int attempts) {
|
||||
return new CustomerDimensionInitializationTask(10001L, 9001L, "iam-customer-create:10001",
|
||||
return new CustomerDimensionInitializationTask(10001L, 9001L, "柏悦物流", "iam-customer-create:10001",
|
||||
action, "SENDING", attempts, 3);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,15 @@ SELECT 'platform_admin_not_fully_granted'
|
||||
WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission WHERE role_id = 4000001)
|
||||
<> (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND permission_type IN ('MENU', 'BUTTON'))
|
||||
UNION ALL
|
||||
SELECT 'missing_order_declaration_edit_permission'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM baiyue_iam.iam_permission
|
||||
WHERE id = 5102027 AND tenant_id = 0 AND app_code = 'PLATFORM'
|
||||
AND permission_code = 'platform:order:declaration:edit' AND status = 'ACTIVE' AND deleted = 0
|
||||
)
|
||||
OR (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission
|
||||
WHERE tenant_id = 0 AND permission_id = 5102027 AND role_id IN (4000001, 4000003)) <> 2
|
||||
UNION ALL
|
||||
SELECT 'missing_global_merchant_owner_template'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM baiyue_iam.iam_role
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user