Merge test fixes into dev dropship flow

This commit is contained in:
2026-09-10 18:18:41 +08:00
131 changed files with 3062 additions and 651 deletions
+3 -1
View File
@@ -20,7 +20,7 @@
|---|---|---| |---|---|---|
| 登录、Token、密码、注册与顶层授权快照 | `auth/` | `auth/AGENTS.md`;海外 Token 只携带稳定身份与版本,角色、权限和冻结海外仓从登录/刷新响应顶层快照读取,海外 Gateway 再按 IAM 安全投影失败关闭校验 | | 登录、Token、密码、注册与顶层授权快照 | `auth/` | `auth/AGENTS.md`;海外 Token 只携带稳定身份与版本,角色、权限和冻结海外仓从登录/刷新响应顶层快照读取,海外 Gateway 再按 IAM 安全投影失败关闭校验 |
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` | | 客户档案、成员、审核、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/` 定位;业务事件生产者回到所属服务 | | 消息中心、站内通知、消息模板、收件箱 | `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 准备中状态覆盖 | | 订单、货物、快照、状态、幂等、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` | | 客户头程订单中文批量导入 | `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。 - 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
- 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。 - 修改 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 前执行该门禁。 - 所有生产 MyBatis Mapper 接口必须显式标记 `@Mapper`;每个持久化服务只允许在启动类使用一次服务根包 `@MapperScan(..., annotationClass = Mapper.class)`,不得新增子域包白名单或分散的 `*MapperConfiguration`。新增或移动 Mapper 后先运行 `node scripts/mapper-scan-contract.mjs`,全量后端回归也会在 Maven 前执行该门禁。
Vendored
+10
View File
@@ -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 测试门禁') { stage('统一 Maven 测试门禁') {
when { when {
expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS } expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS }
@@ -28,8 +28,13 @@ public record FirstLegContainerShippingNodeRecordedV1(
"EMPTY_RETURNED", 100); "EMPTY_RETURNED", 100);
public FirstLegContainerShippingNodeRecordedV1 { public FirstLegContainerShippingNodeRecordedV1 {
if (queryId == null || queryId.isBlank() || oceanBookingId == null || oceanBookingId.isBlank() if (queryId != null && queryId.isBlank()) {
|| containerBookingId == null || containerBookingId.isBlank() throw new IllegalArgumentException("船节点跟踪任务标识不能为空字符串");
}
if (oceanBookingId != null && oceanBookingId.isBlank()) {
throw new IllegalArgumentException("船节点订舱标识不能为空字符串");
}
if (containerBookingId == null || containerBookingId.isBlank()
|| containerNo == null || containerNo.isBlank()) { || containerNo == null || containerNo.isBlank()) {
throw new IllegalArgumentException("船节点交接业务标识不能为空"); throw new IllegalArgumentException("船节点交接业务标识不能为空");
} }
@@ -42,11 +42,10 @@ components:
destinationType: {type: string, enum: [FBA, OVERSEAS]} destinationType: {type: string, enum: [FBA, OVERSEAS]}
FirstLegContainerShippingNodeRecordedV1: FirstLegContainerShippingNodeRecordedV1:
type: object 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: properties:
queryId: {type: string} queryId: {type: [string, 'null'], description: Automatic tracking task ID; null for a direct platform manual node.}
customerId: {type: string} oceanBookingId: {type: [string, 'null'], description: Ocean booking ID when the physical container has already been linked; null is valid for a direct manual node.}
oceanBookingId: {type: string}
sourceContainerBookingId: {type: string, minLength: 1} sourceContainerBookingId: {type: string, minLength: 1}
containerNo: {type: string, minLength: 1, description: Uppercase normalized physical container number; never cabinetNo or a technical identity} containerNo: {type: string, minLength: 1, description: Uppercase normalized physical container number; never cabinetNo or a technical identity}
standardNode: {type: string} standardNode: {type: string}
@@ -35,6 +35,19 @@ class FirstLegContainerShippingNodeRecordedV1Test {
.hasMessageContaining("normalized"); .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 @Test
void rejectsEstimatedOnlyOrIncompleteProviderFacts() { void rejectsEstimatedOnlyOrIncompleteProviderFacts() {
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
+11 -3
View File
@@ -1,8 +1,9 @@
# 测试环境 SQL 执行说明 # 测试环境 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 只作可重建镜像。 - 海外区域固定入口:`海外区域统一增量-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 的该行,其他既有开发账号不建立兼容表或长期补投入口。 - 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 均需用户单独授权。 - 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。
@@ -25,6 +26,13 @@
4. 每个增量区块末尾增加只读断言,并同步维护统一脚本末尾的总体验证。 4. 每个增量区块末尾增加只读断言,并同步维护统一脚本末尾的总体验证。
5. MySQL DDL 会隐式提交,执行前仍应在目标环境确认连接和授权;不能把跨库脚本视为可整体回滚事务。 5. MySQL DDL 会隐式提交,执行前仍应在目标环境确认连接和授权;不能把跨库脚本视为可整体回滚事务。
### 权限点独立入口边界
- 新增权限时,同步维护 IAM 模块来源、`权限点初始化-MySQL8.sql`、对应区域的全量统一 SQL、前端按钮/路由、Controller `@PreAuthorize` 和 OpenAPI。
- 权限点入口只用于既有 IAM 基线上的权限新增或元数据/默认授权更新。涉及建表、字段、索引、角色创建或其他业务数据时,仍执行全量统一 SQL。
- 独立入口必须可重复执行,执行前校验稳定权限 ID/权限码没有被其他语义占用,执行后只在授权完整时返回 `permission_initialization_status = PASS`。
- 权限写入后不修改 Redis 中已签发的会话快照;受影响用户必须退出并重新登录。权限撤销不通过该 SQL 直接处理,应走 IAM 角色授权命令以同步撤销会话。
## Finance 首期人工财务基线 ## Finance 首期人工财务基线
- Finance 数据库为 `baiyue_finance`,模块溯源脚本为 `finance/src/main/resources/db/finance.sql`,测试和存量环境仍只执行统一入口。 - 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` 确认模板存在且权限完整。 固定入口同时幂等补齐审核自动开户所需的全局 `MERCHANT_OWNER` 角色模板、全局商户菜单/按钮权限和角色授权;执行后应使用 `iam/src/test/resources/db/verify-phase1-admin-rbac.sql` 确认模板存在且权限完整。
测试环境只执行当前固定入口;历史脚本和模块脚本不应在固定入口执行后再次串行执行。 测试环境结构发布只执行全量固定入口;纯权限点发布可改用权限点独立入口。历史脚本和模块脚本不应在任一发布入口执行后再次串行执行。
## 执行方式 ## 执行方式
+507 -161
View File
@@ -1345,18 +1345,19 @@ VALUES
(5102019, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:create', '平台代客创建订单', NULL, NULL, 90, 'ACTIVE', 0), (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), (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), (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 ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), status = 'ACTIVE', deleted = 0; 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) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0 FROM iam_permission 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); ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000003, id, 0 FROM iam_permission 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); ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES 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 '生效结束时间', effective_to DATETIME(3) NULL COMMENT '生效结束时间',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态:ACTIVE启用、DISABLED停用', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态:ACTIVE启用、DISABLED停用',
remark VARCHAR(500) NULL COMMENT '规则备注', remark VARCHAR(500) NULL COMMENT '规则备注',
initialization_key VARCHAR(128) NULL COMMENT '客户创建执行器幂等键及补偿归属',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', 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 '乐观锁版本号', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_charge_rule_version (tenant_id, rule_code, rule_version), 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) KEY idx_charge_rule_match (tenant_id, customer_id, business_type, status, effective_from, effective_to)
) ENGINE=InnoDB COMMENT='客户头程计费规则版本'; ) 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', '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', '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', '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_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', '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"); 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; 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 ( CREATE TABLE IF NOT EXISTS fl_price_channel_catalog (
id BIGINT UNSIGNED NOT NULL COMMENT '渠道目录ID', id BIGINT UNSIGNED NOT NULL COMMENT '渠道目录ID',
channel_code VARCHAR(64) NOT NULL COMMENT '稳定渠道编码', 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 -- 来源:firstleg/src/main/resources/db/phase3-smart-loading-schema.sql
-- ============================================================
SET NAMES utf8mb4; SET NAMES utf8mb4;
CREATE DATABASE IF NOT EXISTS baiyue_firstleg 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 '提单号', bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
vessel_name VARCHAR(128) NULL COMMENT '船名快照', vessel_name VARCHAR(128) NULL COMMENT '船名快照',
voyage_no VARCHAR(64) 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_id BIGINT UNSIGNED NULL COMMENT '船司ID快照',
shipping_company_name VARCHAR(128) NULL COMMENT '船司名称快照', shipping_company_name VARCHAR(128) NULL COMMENT '船司名称快照',
departure_port_code VARCHAR(32) 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 '实际到港时间', actual_port_arrival_at DATETIME(3) NULL COMMENT '实际到港时间',
estimated_warehouse_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间', estimated_warehouse_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间',
actual_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 VARCHAR(64) NULL COMMENT 'PU数字码;按字符串保存以保留前导零',
pickup_code_updated_by BIGINT UNSIGNED NULL COMMENT 'PU码最后填写人ID', pickup_code_updated_by BIGINT UNSIGNED NULL COMMENT 'PU码最后填写人ID',
pickup_code_updated_by_name VARCHAR(64) NULL COMMENT 'PU码最后填写人名称快照', 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 '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照', updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜资料最后更新时间',
COMMENT '货柜资料最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_container_booking_no (booking_no), 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_merge (record_status,merged_into_container_booking_id),
KEY idx_container_booking_warehouse (warehouse_id, updated_at, id), KEY idx_container_booking_warehouse (warehouse_id, updated_at, id),
KEY idx_container_booking_voyage (voyage_no, updated_at), 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_source CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING')),
CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')), 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_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_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_pickup_code CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$'),
CONSTRAINT chk_container_booking_iso6346 CHECK CONSTRAINT chk_container_booking_iso6346 CHECK
(container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$'), (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}$')
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划'; ) 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; DROP PROCEDURE IF EXISTS phase3_container_real_unit_columns;
DELIMITER $$ DELIMITER $$
CREATE PROCEDURE phase3_container_real_unit_columns() CREATE PROCEDURE phase3_container_real_unit_columns()
@@ -5139,10 +5168,40 @@ BEGIN
ALTER TABLE fl_container_booking ADD COLUMN customer_name VARCHAR(128) NULL ALTER TABLE fl_container_booking ADD COLUMN customer_name VARCHAR(128) NULL
COMMENT '已关联真实订舱客户名称快照' AFTER customer_id; COMMENT '已关联真实订舱客户名称快照' AFTER customer_id;
END IF; 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() 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 AND table_name='fl_container_booking' AND column_name='pickup_code') THEN
ALTER TABLE fl_container_booking ADD COLUMN pickup_code VARCHAR(64) NULL 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; END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() 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 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 ALTER TABLE fl_container_booking ADD COLUMN pickup_code_updated_at DATETIME(3) NULL
COMMENT 'PU码最后填写时间' AFTER pickup_code_updated_by_name; COMMENT 'PU码最后填写时间' AFTER pickup_code_updated_by_name;
END IF; 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() 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 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); 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)', id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID(雪花ID)',
plan_no VARCHAR(40) NOT NULL COMMENT '平台唯一装柜计划号', plan_no VARCHAR(40) NOT NULL COMMENT '平台唯一装柜计划号',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING' plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING' COMMENT '装柜计划状态:EDITING编辑中、LOADING装柜中、COMPLETED装柜完成;建议须经人工确认',
COMMENT '装柜计划状态:EDITING编辑中、LOADING装柜中、COMPLETED装柜完成;建议须经人工确认',
preload_completed_by BIGINT UNSIGNED NULL COMMENT '预装完成操作人ID', preload_completed_by BIGINT UNSIGNED NULL COMMENT '预装完成操作人ID',
preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照', preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照',
preload_completed_at DATETIME(3) 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 '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照', updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜计划最后更新时间',
COMMENT '装柜计划最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_loading_plan_no (plan_no), 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) CONSTRAINT chk_loading_pallet_index CHECK (pallet_index BETWEEN 1 AND 999)
) ENGINE=InnoDB COMMENT='散货按柜号和FBA仓冻结的可打印托盘码'; ) ENGINE=InnoDB COMMENT='散货按柜号和FBA仓冻结的可打印托盘码';
-- 柜次级简单文件仅承载目的港清关和提货单;PU资料作为数字码保存在货柜主表。
CREATE TABLE IF NOT EXISTS fl_container_document ( CREATE TABLE IF NOT EXISTS fl_container_document (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)', id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订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_by_name VARCHAR(64) NULL COMMENT '一键完成操作人名称快照',
completed_at DATETIME(3) NULL COMMENT '一键完成时间', completed_at DATETIME(3) NULL COMMENT '一键完成时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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) updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜文件关联最后更新时间',
COMMENT '货柜文件关联最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_container_document_type (container_booking_id, document_type), 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')) CHECK (document_type IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER'))
) ENGINE=InnoDB COMMENT='平台货柜清关与DO文件关联'; ) 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 ( CREATE TABLE IF NOT EXISTS fl_loading_plan_history (
id BIGINT UNSIGNED NOT NULL COMMENT '装柜操作历史ID(雪花ID)', id BIGINT UNSIGNED NOT NULL COMMENT '装柜操作历史ID(雪花ID)',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID', loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID',
loading_plan_item_id BIGINT UNSIGNED NULL COMMENT '装柜计划明细ID', loading_plan_item_id BIGINT UNSIGNED NULL COMMENT '装柜计划明细ID',
operation_id VARCHAR(128) NOT NULL COMMENT '操作幂等ID', operation_id VARCHAR(128) NOT NULL COMMENT '操作幂等ID',
action_type VARCHAR(32) NOT NULL action_type VARCHAR(32) NOT NULL COMMENT '动作类型:CREATE_PLAN创建计划、ADD_ITEM新增明细、ADJUST_ITEM调整明细、REMOVE_ITEM移除明细、START_LOADING开始装柜、COMPLETE_LOADING完成装柜、PICK_ITEM拣货',
COMMENT '动作类型:CREATE_PLAN创建计划、ADD_ITEM新增明细、ADJUST_ITEM调整明细、REMOVE_ITEM移除明细、START_LOADING开始装柜、COMPLETE_LOADING完成装柜、PICK_ITEM拣货',
from_status VARCHAR(24) NULL COMMENT '变更前状态', from_status VARCHAR(24) NULL COMMENT '变更前状态',
to_status VARCHAR(24) NULL COMMENT '变更后状态', to_status VARCHAR(24) NULL COMMENT '变更后状态',
before_snapshot JSON NULL COMMENT '变更前快照', before_snapshot JSON NULL COMMENT '变更前快照',
@@ -5399,26 +5477,27 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_history (
-- fl_loading_pick_task 曾被另一套入库汇总模型复用。先按字段签名识别并迁走, -- fl_loading_pick_task 曾被另一套入库汇总模型复用。先按字段签名识别并迁走,
-- 再创建智能装柜任务表;未知或混合结构必须人工处理,禁止静默套用错误表型。 -- 再创建智能装柜任务表;未知或混合结构必须人工处理,禁止静默套用错误表型。
DROP PROCEDURE IF EXISTS assert_loading_pick_task_shapes; DROP PROCEDURE IF EXISTS migrate_legacy_loading_pick_task;
DELIMITER $$ DELIMITER $$
CREATE PROCEDURE assert_loading_pick_task_shapes(IN require_both TINYINT) CREATE PROCEDURE migrate_legacy_loading_pick_task()
BEGIN BEGIN
DECLARE source_exists INT DEFAULT 0; 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 target_exists INT DEFAULT 0;
DECLARE source_total INT DEFAULT 0; DECLARE target_total_columns INT DEFAULT 0;
DECLARE source_smart INT DEFAULT 0; DECLARE target_inbound_columns INT DEFAULT 0;
DECLARE source_stable_smart INT DEFAULT 0;
DECLARE source_inbound INT DEFAULT 0; SELECT COUNT(*) INTO source_exists
DECLARE target_total INT DEFAULT 0; FROM information_schema.tables
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'; 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 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'; 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 ( 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') (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 = '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' 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_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 = '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')); 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');
SELECT COUNT(*) INTO source_inbound_columns SELECT COUNT(*) INTO source_inbound_columns
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND (
AND column_name IN ('tenant_id', 'inbound_id', 'status', 'expected_count', 'picked_count'); (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; 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 SELECT COUNT(*) INTO target_exists
FROM information_schema.tables FROM information_schema.tables
WHERE table_schema = 'baiyue_firstleg' WHERE table_schema = 'baiyue_firstleg'
@@ -5544,14 +5567,44 @@ BEGIN
IF target_exists = 0 THEN IF target_exists = 0 THEN
RENAME TABLE fl_loading_pick_task TO fl_inbound_loading_pick_task; RENAME TABLE fl_loading_pick_task TO fl_inbound_loading_pick_task;
ELSE 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 SELECT COUNT(*) INTO target_inbound_columns
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_inbound_loading_pick_task' AND table_name = 'fl_inbound_loading_pick_task' AND (
AND column_name IN ('tenant_id', 'inbound_id', 'status', 'expected_count', 'picked_count'); (column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
IF target_inbound_columns <> 5 THEN 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' SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '拣货任务迁移失败:fl_inbound_loading_pick_task 不是入库汇总结构'; SET MESSAGE_TEXT = '拣货任务迁移失败:fl_inbound_loading_pick_task 不是完整入库汇总结构';
END IF; END IF;
INSERT INTO fl_inbound_loading_pick_task INSERT INTO fl_inbound_loading_pick_task
(id, tenant_id, inbound_id, status, version, expected_count, picked_count, (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')) CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED','COMPLETED'))
) ENGINE=InnoDB COMMENT='PDA装柜拣货任务'; ) 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 ( CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID', id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
pick_task_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 '计划拣货箱数;上层拆分按业务规则向上取整', planned_package_count INT UNSIGNED NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整',
picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 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跳拣', 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 '乐观锁版本号', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货明细创建时间', 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拣货明细最后更新时间', 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) CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id)
) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史'; ) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史';
DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys; DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys;
DELIMITER $$ DELIMITER $$
CREATE PROCEDURE repair_phase3_loading_pick_task_foreign_keys() CREATE PROCEDURE repair_phase3_loading_pick_task_foreign_keys()
@@ -6031,8 +6127,10 @@ BEGIN
OR NOT (NEW.cabinet_no <=> OLD.cabinet_no) THEN OR NOT (NEW.cabinet_no <=> OLD.cabinet_no) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务稳定归属不可修改'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务稳定归属不可修改';
END IF; END IF;
IF NOT (OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING', 'COMPLETED')) THEN IF NOT ((OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING','COMPLETING'))
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态只允许完成'; 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 IF;
END$$ END$$
DELIMITER ; DELIMITER ;
@@ -6194,10 +6292,11 @@ DELIMITER ;
-- 三期智能装柜、预装清单和装柜清单平台权限;与一期平台根菜单幂等合并。 -- 三期智能装柜、预装清单和装柜清单平台权限;与一期平台根菜单幂等合并。
USE baiyue_iam; USE baiyue_iam;
DROP PROCEDURE IF EXISTS assert_smart_loading_permission_ids_available; DROP PROCEDURE IF EXISTS relocate_legacy_inbound_loading_permissions;
DELIMITER $$ DELIMITER $$
CREATE PROCEDURE assert_smart_loading_permission_ids_available() CREATE PROCEDURE relocate_legacy_inbound_loading_permissions()
BEGIN BEGIN
DECLARE legacy_count INT DEFAULT 0;
IF EXISTS ( IF EXISTS (
SELECT 1 FROM iam_permission SELECT 1 FROM iam_permission
WHERE id IN (5102091, 5102092, 5102093, 5102094, 5102096, 5102097, 5102098, 5102099) WHERE id IN (5102091, 5102092, 5102093, 5102094, 5102096, 5102097, 5102098, 5102099)
@@ -6215,10 +6314,89 @@ BEGIN
SIGNAL SQLSTATE '45000' SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '三期权限迁移失败:5102091-5102099 存在未知权限占用'; SET MESSAGE_TEXT = '三期权限迁移失败:5102091-5102099 存在未知权限占用';
END IF; 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$$ END$$
DELIMITER ; DELIMITER ;
CALL assert_smart_loading_permission_ids_available(); CALL relocate_legacy_inbound_loading_permissions();
DROP PROCEDURE assert_smart_loading_permission_ids_available; DROP PROCEDURE relocate_legacy_inbound_loading_permissions;
DELETE role_permission FROM iam_role_permission role_permission DELETE role_permission FROM iam_role_permission role_permission
JOIN iam_permission permission ON permission.id = role_permission.permission_id 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:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create', 'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list', '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:list', 'platform:firstleg:preload:view',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete', 'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list', 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
@@ -6285,6 +6463,12 @@ WHERE role.tenant_id = 0
USE baiyue_firstleg; USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema; 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 $$ DELIMITER $$
CREATE PROCEDURE assert_phase3_smart_loading_schema() CREATE PROCEDURE assert_phase3_smart_loading_schema()
BEGIN BEGIN
@@ -6426,9 +6610,118 @@ BEGIN
END$$ END$$
DELIMITER ; 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 校验之前,可重复执行。 -- 来源:warehouse/src/main/resources/db/warehouse-public-master-data.sql。必须位于最终 PASS 校验之前,可重复执行。
USE baiyue_warehouse; 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_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_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 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 CONSTRAINT chk_carrier_schedule_query_carrier CHECK
(carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')), (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')),
CONSTRAINT chk_carrier_schedule_query_tracking CHECK 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 ( CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
id BIGINT UNSIGNED NOT NULL COMMENT '标准船节点事实ID', 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组成的幂等键', event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键',
provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码', provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码',
provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID', 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;不保存敏感原文', raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
PRIMARY KEY (id), 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_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_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 CONSTRAINT chk_carrier_schedule_node_standard CHECK
(standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT', (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')), 'IN_TRANSIT','ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')),
@@ -11109,6 +11405,34 @@ END$$
DELIMITER ; DELIMITER ;
CALL assert_carrier_schedule_query_schema(); CALL assert_carrier_schedule_query_schema();
DROP PROCEDURE IF EXISTS 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; DROP PROCEDURE IF EXISTS migrate_carrier_schedule_retry_columns;
DELIMITER $$ 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_from', '生效开始时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'effective_to', '生效结束时间'); 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', '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', 'remark', '规则备注');
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'rounding_mode', '舍入方式'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'rounding_mode', '舍入方式');
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'rounding_scale', '结果保留小数位数'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'rounding_scale', '结果保留小数位数');
@@ -15754,6 +16079,16 @@ BEGIN
SIGNAL SQLSTATE '45000' SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:管理员或订单运营角色缺少订单导出与下载中心权限'; SET MESSAGE_TEXT = '统一迁移失败:管理员或订单运营角色缺少订单导出与下载中心权限';
END IF; 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 IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM' WHERE tenant_id = 0 AND app_code = 'PLATFORM'
AND id BETWEEN 5102121 AND 5102129 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)')); 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; 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(); CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
SELECT 'PASS' AS migration_status, SELECT 'PASS' AS migration_status,
+66
View File
@@ -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 与海外仓空库基线,再执行统一增量和最终结构校验。 -- 本文件内联 Lastleg 与海外仓空库基线,再执行统一增量和最终结构校验。
-- 本文件只由人工审核执行,服务部署默认不自动执行 SQL。 -- 本文件只由人工审核执行,服务部署默认不自动执行 SQL。
-- 空库先执行 lastleg-service/src/main/resources/db/lastleg.sql 基线;本入口同时内联该基线用于固定部署。
SET NAMES utf8mb4; SET NAMES utf8mb4;
-- 海外 Event Bridge 空库基线必须先于业务服务表初始化。 -- 海外 Event Bridge 空库基线必须先于业务服务表初始化。
@@ -112,7 +113,7 @@ CREATE TABLE IF NOT EXISTS ll_loading_event_inbox (
CREATE TABLE IF NOT EXISTS ll_container_shipping_gate ( CREATE TABLE IF NOT EXISTS ll_container_shipping_gate (
container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID', 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', 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 '最新有效节点单调序号', 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 '节点实际发生时间', 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 认领;装柜完成只建立等待门禁快照。 -- 海运节点达到可提柜后才允许海外 PDA 认领;装柜完成只建立等待门禁快照。
CREATE TABLE IF NOT EXISTS ll_container_shipping_gate ( CREATE TABLE IF NOT EXISTS ll_container_shipping_gate (
container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID', 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 '实际集装箱号', container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号',
status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE', status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE',
standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点', 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; DROP PROCEDURE IF EXISTS assert_overseas_finance_p0_ready;
SELECT 'PASS' AS migration_status, '海外财务P0费用来源字段已就绪' AS message; 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
View File
@@ -15,7 +15,7 @@
| 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` | | 测量图片/证据 | `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/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` | | 装柜与业务文件证据 | `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 | | 海外异常订单证据 | 通用内部业务文件入口;固定 `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 校验申请/明细关系、客户归属、权限和业务状态 | | 海外换标 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 校验 | | 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 |
@@ -56,7 +56,8 @@ enum BusinessFileCategory {
void validateOwner(long tenantId) { void validateOwner(long tenantId) {
if ((this == OCEAN_BOOKING_DOCUMENT || this == OVERSEAS_UNLOAD_PHOTO) && tenantId != 0) { 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) { if (customerOwned && tenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "该附件类型必须归属明确客户"); 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 -> when(storage.store(eq(0L), anyString(), same(file), any(BusinessFileCategory.class))).thenAnswer(invocation ->
new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf", new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf",
"application/pdf", file.getSize(), "b".repeat(64), "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 -> { when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(81L); return 1; ((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, "41", file, BusinessFileCategory.CONTAINER_PICKUP_ORDER);
verify(storage).store(0L, "51", file, BusinessFileCategory.OVERSEAS_UNLOAD_PHOTO); verify(storage).store(0L, "51", file, BusinessFileCategory.OVERSEAS_UNLOAD_PHOTO);
assertThatThrownBy(() -> service.upload(11L, "OVERSEAS_UNLOAD_PHOTO", "51", 7L, file)) assertThatThrownBy(() -> service.upload(11L, "OVERSEAS_UNLOAD_PHOTO", "51", 7L, file))
.isInstanceOf(BizException.class).hasMessageContaining("必须归属平台"); .isInstanceOf(BizException.class).hasMessageContaining("卸柜照片必须归属平台");
} }
@Test void oceanBookingDocumentUsesPlatformAndBookingOwnership() { @Test void oceanBookingDocumentUsesPlatformAndBookingOwnership() {
+2 -2
View File
@@ -86,8 +86,8 @@
## 国内货柜履约装柜与 PDA 资源结算 ## 国内货柜履约装柜与 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 全部明细处理完成后自动完成。 - 智能装柜全局工作台由服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `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` 与幂等键。成功后记录确认成员和时间并将该费用版本永久锁定,平台不得再调整;旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 - 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 - PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。装柜允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责;PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 - 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,不阻断确认。 - 首张称重图片确认时按 `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`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。 - 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。
@@ -41,7 +41,7 @@ public class InternalCustomerDimensionConfigController {
@Valid @RequestBody InitializeRequest request) { @Valid @RequestBody InitializeRequest request) {
requireToken(token); requireToken(token);
return ApiResponse.success(service.initializeDefault( return ApiResponse.success(service.initializeDefault(
request.tenantId(), request.operatorId(), request.initializationKey())); request.tenantId(), request.operatorId(), request.initializationKey(), request.customerName()));
} }
@DeleteMapping("/defaults") @DeleteMapping("/defaults")
@@ -92,7 +92,6 @@ public class PlatformContainerController {
@PathVariable @Positive long id, @PathVariable @Positive long id,
@PathVariable @Positive long fileId) { @PathVariable @Positive long fileId) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
service.requireDomestic(id);
var value = overview.downloadPhoto(id, fileId); var value = overview.downloadPhoto(id, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()) return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.inline() .header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.inline()
@@ -93,6 +93,40 @@ public class PlatformInboundMeasurementController {
return ApiResponse.success(service.saveDraft(tenantId, inboundId, user.userId(), request)); 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", @BusinessAudit(event = "INBOUND_MEASUREMENT_SUBMIT", resourceType = "INBOUND",
resourceId = "#inboundId", description = "确认测量并提交审核") resourceId = "#inboundId", description = "确认测量并提交审核")
@Operation(summary = "确认测量并提交审核", @Operation(summary = "确认测量并提交审核",
@@ -10,6 +10,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundOrderDetail; import com.baiyue.firstleg.dto.InboundOrderDetail;
import com.baiyue.firstleg.dto.InboundOrderQuery; import com.baiyue.firstleg.dto.InboundOrderQuery;
import com.baiyue.firstleg.dto.InboundOrderSummary; 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.CommandRequest;
import com.baiyue.firstleg.dto.InboundHoldDtos.Detail; import com.baiyue.firstleg.dto.InboundHoldDtos.Detail;
import com.baiyue.firstleg.service.FirstlegScopeGuard; 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 io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated; 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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; 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.RequestHeader;
import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
@@ -160,6 +163,18 @@ public class PlatformInboundOrderController {
return cartonLabelPrintService.generate(tenantId, inboundId, cartonIndex, requestId); 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) { private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
FirstlegScopeGuard.platform(user, tenantId); FirstlegScopeGuard.platform(user, tenantId);
} }
@@ -22,7 +22,8 @@ public final class ContainerCustomsDtos {
boolean frozen, FileView completion) {} boolean frozen, FileView completion) {}
public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId, 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, int version, int taxRefundCount, int coveredTaxRefundCount,
List<InboundView> inbounds, List<DocumentView> documents, List<InboundView> inbounds, List<DocumentView> documents,
LocalDateTime startedAt, LocalDateTime completedAt) {} LocalDateTime startedAt, LocalDateTime completedAt) {}
@@ -34,6 +34,15 @@ public interface ContainerCustomsMapper {
""") """)
List<InboundRow> planInbounds(@Param("planId") long planId); 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("""
SELECT p.id FROM fl_loading_plan p 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 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, record InboundRow(Long loadingPlanId,String planNo,Long containerBookingId,String loadingPlanStatus,
String cabinetNo,String containerNo,Long inboundId,Long orderId,String orderNo,String inboundCode, String cabinetNo,String containerNo,Long inboundId,Long orderId,String orderNo,String inboundCode,
Long tenantId,String customerName,String taxMode) {} 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, record ContainerRow(Long id,Long loadingPlanId,Long containerBookingId,String status,Long startedBy,
String startedByName,LocalDateTime startedAt,Long completedBy,String completedByName, String startedByName,LocalDateTime startedAt,Long completedBy,String completedByName,
LocalDateTime completedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) {} LocalDateTime completedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) {}
@@ -44,6 +44,9 @@ public class ContainerCustomsService {
@Transactional @Transactional
public ContainerView platformView(long planId) { 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); ContainerRow container = ensure(planId);
return view(container, null, false); return view(container, null, false);
} }
@@ -155,6 +158,7 @@ public class ContainerCustomsService {
CommandRow replay = claim(operatorId, action, key, requestHash); CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return platformView(planId); if (replay != null) return platformView(planId);
ensure(planId); ensure(planId);
if (actualPlanRows(planId).isEmpty()) throw conflict("当前装柜计划没有实际货物,无需报关");
ContainerRow locked = mapper.lockContainer(planId); ContainerRow locked = mapper.lockContainer(planId);
if (locked == null || !READY.equals(locked.status())) throw conflict("当前柜报关资料尚未齐全或已开始报关"); if (locked == null || !READY.equals(locked.status())) throw conflict("当前柜报关资料尚未齐全或已开始报关");
if (!Objects.equals(locked.version(), request.expectedVersion())) throw concurrent(); if (!Objects.equals(locked.version(), request.expectedVersion())) throw concurrent();
@@ -219,7 +223,8 @@ public class ContainerCustomsService {
ContainerRow container = mapper.lockContainer(planId); ContainerRow container = mapper.lockContainer(planId);
if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中"); if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中");
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent(); 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"); if(actual.stream().anyMatch(row->!"BUY_DECLARATION".equals(row.taxMode())))throw conflict("全买单柜完成只允许全部实际行均为BUY_DECLARATION");
completeContainer(container, operatorId, operatorName, request.remark()); completeContainer(container, operatorId, operatorName, request.remark());
completeCommand(operatorId, action, key, container.id()); completeCommand(operatorId, action, key, container.id());
@@ -245,11 +250,11 @@ public class ContainerCustomsService {
} }
private ContainerRow ensure(long planId) { private ContainerRow ensure(long planId) {
List<InboundRow> rows = planRows(planId); List<InboundRow> rows = actualPlanRows(planId);
InboundRow first = rows.get(0); PlanRow plan = rows.isEmpty() ? requiredPlan(planId) : plan(rows.get(0));
if (!"COMPLETED".equals(first.loadingPlanStatus())) throw conflict("装柜完成后才能处理报关资料"); if (!"COMPLETED".equals(plan.loadingPlanStatus())) throw conflict("装柜完成后才能处理报关资料");
boolean refund = rows.stream().anyMatch(row -> "TAX_REFUND".equals(row.taxMode())); 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); return refresh(planId);
} }
@@ -283,11 +288,27 @@ public class ContainerCustomsService {
} }
private List<InboundRow> planRows(long planId) { private List<InboundRow> planRows(long planId) {
List<InboundRow> rows = mapper.planInbounds(planId); List<InboundRow> rows = actualPlanRows(planId);
if (rows == null || rows.isEmpty()) throw notFound("未找到当前装柜计划的实际货物明细"); if (rows == null || rows.isEmpty()) throw notFound("未找到当前装柜计划的实际货物明细");
return rows; 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) { private List<InboundRow> validateScope(long planId, long tenantId, List<Long> inboundIds) {
if (inboundIds == null || inboundIds.isEmpty() || inboundIds.size() > 200) throw bad("至少选择一个且最多200个入仓码"); if (inboundIds == null || inboundIds.isEmpty() || inboundIds.size() > 200) throw bad("至少选择一个且最多200个入仓码");
Set<Long> requested = new LinkedHashSet<>(inboundIds); Set<Long> requested = new LinkedHashSet<>(inboundIds);
@@ -304,7 +325,8 @@ public class ContainerCustomsService {
} }
private ContainerView view(ContainerRow container, Long tenantId, boolean merchant) { 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); 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()); 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(); 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 refund = (int) visibleRows.stream().filter(row -> "TAX_REFUND".equals(row.taxMode())).count();
int coveredRefund = (int) visibleRows.stream().filter(row -> covered.contains(row.inboundId())).count(); int coveredRefund = (int) visibleRows.stream().filter(row -> covered.contains(row.inboundId())).count();
String merchantStatus=tenantId==null?null:merchantStatus(container.status(),refund,coveredRefund); String merchantStatus=tenantId==null?null:merchantStatus(container.status(),refund,coveredRefund);
InboundRow first = rows.get(0); return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), plan.planNo(),
return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), first.planNo(), plan.cabinetNo(), plan.containerNo(), container.status(), !rows.isEmpty(), merchantStatus, container.version(), refund, coveredRefund,
first.cabinetNo(), first.containerNo(), container.status(), merchantStatus, container.version(), refund, coveredRefund,
inbounds, documentViews, container.startedAt(), container.completedAt()); inbounds, documentViews, container.startedAt(), container.completedAt());
} }
@@ -60,8 +60,10 @@ public final class CustomerDimensionConfigDtos {
public record InitializeRequest( public record InitializeRequest(
@NotNull @Min(1) Long tenantId, @NotNull @Min(1) Long tenantId,
@NotNull @Min(0) Long operatorId, @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.math.BigDecimal;
import java.util.List; import java.util.List;
import jakarta.validation.constraints.Min;
/** 稳定箱号分配、补打和测量解析契约。 */ /** 稳定箱号分配、补打和测量解析契约。 */
public final class InboundCartonLabels { public final class InboundCartonLabels {
private InboundCartonLabels() { private InboundCartonLabels() {
@@ -12,6 +14,9 @@ public final class InboundCartonLabels {
List<Label> labels) { 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, public record Label(Long id, Long inboundDetailId, Integer cargoItemNo, String cargoName,
Integer detailPackageCount, String boxCode, Integer totalCount, Integer detailPackageCount, String boxCode, Integer totalCount,
Integer cartonIndex, String boxSpecification, BigDecimal lengthCm, 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( public record ConfirmedPackageCountRequest(
@NotNull @PositiveOrZero Integer measurementVersion, @NotNull @PositiveOrZero Integer measurementVersion,
@NotNull @Positive @Max(1000000) Integer confirmedPackageCount @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 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, public record MeasurementSubmitted(Long measurementId, Integer version, String processStatus,
String measureReviewStatus, String measureReviewStatus,
@Schema(description = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘", @Schema(description = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘",
@@ -38,4 +38,6 @@ public class CustomerChargeRuleEntity extends BaseEntity {
private LocalDateTime effectiveTo; private LocalDateTime effectiveTo;
private String status; private String status;
private String remark; 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.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.loading.repository.LoadingRepository; import com.baiyue.firstleg.loading.repository.LoadingRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.service.FbaPricingService;
import com.baiyue.firstleg.service.InboundHoldGuard; import com.baiyue.firstleg.service.InboundHoldGuard;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -42,7 +41,6 @@ public class LoadingDeliveryService {
@Resource private LoadingCompletionGateService completionGate; @Resource private LoadingCompletionGateService completionGate;
@Resource private InboundStatusHistoryMapper histories; @Resource private InboundStatusHistoryMapper histories;
@Resource private CabinetLoadingEvidenceService cabinetEvidenceService; @Resource private CabinetLoadingEvidenceService cabinetEvidenceService;
@Resource private FbaPricingService pricingService;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Resource private InboundHoldGuard inboundHoldGuard; @Resource private InboundHoldGuard inboundHoldGuard;
@@ -69,7 +67,6 @@ public class LoadingDeliveryService {
} }
if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) { if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) {
requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion); requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion);
pricingService.autoConfirmAfterLoading(tenantId, inboundId);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse(inbound.processStatus())); new LoadingActionResponse(inbound.processStatus()));
} }
@@ -80,7 +77,6 @@ public class LoadingDeliveryService {
requireCabinetEvidence(tenantId, inboundId); requireCabinetEvidence(tenantId, inboundId);
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent(); if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName); addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName);
pricingService.autoConfirmAfterLoading(tenantId, inboundId);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED")); new LoadingActionResponse("LOADED"));
} }
@@ -124,7 +120,6 @@ public class LoadingDeliveryService {
CompletionTarget target, CompletionTarget target,
boolean acceptedPdaCompletion) { boolean acceptedPdaCompletion) {
if ("LOADED".equals(target.loadedChildStatus())) { if ("LOADED".equals(target.loadedChildStatus())) {
pricingService.autoConfirmAfterLoading(tenantId, target.completionInboundId());
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED")); new LoadingActionResponse("LOADED"));
} }
@@ -140,7 +135,6 @@ public class LoadingDeliveryService {
repository.aggregateRootLoaded(tenantId, target.rootInboundId(), operatorId); repository.aggregateRootLoaded(tenantId, target.rootInboundId(), operatorId);
addHistory(tenantId, target.completionInboundId(), "LOADING", "LOADED", COMPLETE_LOADING, addHistory(tenantId, target.completionInboundId(), "LOADING", "LOADED", COMPLETE_LOADING,
request.remark(), operatorId, operatorName); request.remark(), operatorId, operatorName);
pricingService.autoConfirmAfterLoading(tenantId, target.completionInboundId());
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED")); new LoadingActionResponse("LOADED"));
} }
@@ -1,7 +1,50 @@
package com.baiyue.firstleg.mapper; package com.baiyue.firstleg.mapper;
import org.apache.ibatis.annotations.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.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
@Mapper @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_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 JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
AND plan.id = pick_task.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 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 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} WHERE pick_item.pick_task_id = #{taskId} AND plan_item.deleted = 0
AND plan_item.deleted = 0
ORDER BY pick_item.id ASC ORDER BY pick_item.id ASC
""") """)
java.util.List<LoadingPickItemEntity> selectTaskItemsWithAccount(@Param("taskId") long taskId, java.util.List<LoadingPickItemEntity> selectTaskItemsWithAccount(@Param("taskId") long taskId,
@Param("warehouseId") long warehouseId); @Param("warehouseId") long warehouseId);
@Select(""" @Select("""
SELECT pick_item.*, plan_item.unit_type, plan_item.pallet_id, plan_item.pallet_no, plan_item.indivisible 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_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 JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
AND plan.id = pick_task.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 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 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} 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, LoadingPickItemEntity selectTaskItemWithAccount(@Param("taskId") long taskId,
@Param("itemId") long itemId, @Param("itemId") long itemId,
@@ -185,10 +182,8 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
version = version + 1 version = version + 1
WHERE id = #{id} AND version = #{version} WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item 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 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 AND picked_package_count + #{count} <= planned_package_count
""") """)
int pick(@Param("id") long id, @Param("count") int count, int pick(@Param("id") long id, @Param("count") int count,
@@ -201,10 +196,8 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
version = version + 1 version = version + 1
WHERE id = #{id} AND version = #{version} WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item 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 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 item_status IN ('PENDING', 'PICKING')
AND picked_package_count < #{finalCount} AND picked_package_count < #{finalCount}
AND #{finalCount} < planned_package_count AND #{finalCount} < planned_package_count
@@ -218,13 +211,12 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
SET item_status = 'SKIPPED', version = version + 1 SET item_status = 'SKIPPED', version = version + 1
WHERE id = #{id} AND version = #{version} WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item 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 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 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("""
SELECT COUNT(*) SELECT COUNT(*)
@@ -50,7 +50,6 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE booking.container_no = #{containerNo} AND booking.record_status = 'ACTIVE' WHERE booking.container_no = #{containerNo} AND booking.record_status = 'ACTIVE'
AND task.task_status = 'LOADING' AND task.task_status = 'LOADING'
AND booking.warehouse_id = #{warehouseId}
LIMIT 1 LIMIT 1
""") """)
LoadingPickTaskEntity selectLoadingByContainerAndAccount(@Param("containerNo") String containerNo, 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 " @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 " + "JOIN fl_container_booking booking ON booking.id=plan.container_booking_id "
+ "WHERE task.task_status='LOADING' AND booking.record_status='ACTIVE' " + "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); 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 " @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 " + "JOIN fl_container_booking booking ON booking.id=plan.container_booking_id "
+ "WHERE task.task_status='LOADING' AND booking.record_status='ACTIVE' " + "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); LoadingPickTaskEntity selectLoadingByPlan(@Param("loadingPlanId") long loadingPlanId, @Param("warehouseId") long warehouseId);
@Select("SELECT booking.* FROM fl_loading_pick_task task " @Select("SELECT booking.* FROM fl_loading_pick_task task "
@@ -97,7 +97,7 @@ public class OutboundDispatchService {
OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId); OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId);
if (carrierSchedules != null) { if (carrierSchedules != null) {
if (ocean == null || ocean.id() == null || ocean.customerId() == null) { if (ocean == null || ocean.id() == null) {
throw conflict("当前货柜未关联有效订舱,无法创建船司查询任务"); throw conflict("当前货柜未关联有效订舱,无法创建船司查询任务");
} }
carrierSchedules.ensureFromOutboundConfirm(ocean.customerId(), ocean.id(), containerBookingId, carrierSchedules.ensureFromOutboundConfirm(ocean.customerId(), ocean.id(), containerBookingId,
@@ -129,7 +129,7 @@ public class OutboundDispatchService {
if (!Objects.equals(visible.version(), request.expectedVersion())) throw concurrent(); if (!Objects.equals(visible.version(), request.expectedVersion())) throw concurrent();
OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId); OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId);
if (carrierSchedules != null) { if (carrierSchedules != null) {
if (ocean == null || ocean.id() == null || ocean.customerId() == null) { if (ocean == null || ocean.id() == null) {
throw conflict("无法核验货柜后续船节点,禁止撤销国内出仓"); throw conflict("无法核验货柜后续船节点,禁止撤销国内出仓");
} }
if (carrierSchedules.hasOutboundDepartureFact(ocean.customerId(), ocean.id(), containerBookingId)) { if (carrierSchedules.hasOutboundDepartureFact(ocean.customerId(), ocean.id(), containerBookingId)) {
@@ -111,8 +111,7 @@ public class PlatformCarrierScheduleQueryController {
@PostMapping("/{queryId}/manual-nodes") @PostMapping("/{queryId}/manual-nodes")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')") @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
@Operation(summary = "人工补录预计或实际标准船节点", @Operation(summary = "在跟踪任务内人工补录预计或实际标准船节点")
description = "预计节点不推进实际进度;人工实际节点必须严格晚于当前最新实际节点,同级或更早节点返回 A0409。")
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY", @BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY",
resourceId = "#queryId", description = "人工补录船节点事实") resourceId = "#queryId", description = "人工补录船节点事实")
public ApiResponse<Detail> manualNode(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Detail> manualNode(@AuthenticationPrincipal AuthenticatedUser user,
@@ -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,7 +13,7 @@ import java.util.List;
public final class CarrierScheduleQueryDtos { public final class CarrierScheduleQueryDtos {
private 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, @Positive Long containerBookingId, @Size(max = 16) String carrierCode,
@Size(max = 24) String status, Boolean expired, @Size(max = 24) String status, Boolean expired,
@Size(max = 128) String keyword, @Min(1) Long page, @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, 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 oceanBookingId,
@NotNull @Positive Long containerBookingId, @NotNull @Positive Long containerBookingId,
@NotBlank @Size(max = 16) String carrierCode, @NotBlank @Size(max = 16) String carrierCode,
@@ -43,6 +43,14 @@ public final class CarrierScheduleQueryDtos {
@Size(max = 128) String locationName, @Size(max = 128) String locationName,
@NotBlank @Size(max = 1000) String reason) {} @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, public record ProviderObservationRequest(@NotBlank @Size(max = 32) String providerCode,
@NotBlank @Size(max = 128) String providerEventId, @NotBlank @Size(max = 128) String providerEventId,
@NotBlank @Size(max = 64) String providerNodeCode, @NotBlank @Size(max = 64) String providerNodeCode,
@@ -97,6 +105,9 @@ public final class CarrierScheduleQueryDtos {
LocalDateTime actualAt, String locationCode, String locationName, LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {} 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, public record ExceptionFact(Long id, String providerCode, String providerEventId,
String providerStatus, String exceptionCode, boolean active, String providerStatus, String exceptionCode, boolean active,
LocalDateTime observedAt, String locationCode, String locationName, LocalDateTime observedAt, String locationCode, String locationName,
@@ -16,6 +16,7 @@ import java.time.LocalDateTime;
public class CarrierScheduleNodeFactEntity { public class CarrierScheduleNodeFactEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id; @TableId(type = IdType.ASSIGN_ID) private Long id;
private Long queryId; private Long queryId;
private Long containerBookingId;
private String eventKey; private String eventKey;
private String providerCode; private String providerCode;
private String providerEventId; private String providerEventId;
@@ -5,19 +5,29 @@ import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper @Mapper
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> { public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
@Insert(""" @Insert("""
INSERT INTO fl_carrier_schedule_node_fact 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) estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at)
VALUES 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.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt},
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash}, #{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},
CURRENT_TIMESTAMP(3)) CURRENT_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE id=id ON DUPLICATE KEY UPDATE id=id
""") """)
int insertIgnore(@Param("value") CarrierScheduleNodeFactEntity value); 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);
} }
@@ -49,26 +49,26 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
@Select(""" @Select("""
<script> <script>
SELECT q.container_booking_id AS containerBookingId,q.id AS queryId,q.version AS queryVersion, SELECT b.id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
q.status AS queryStatus,q.latest_standard_node AS latestActualNode, q.status AS queryStatus,current.standard_node AS latestActualNode,
q.latest_node_observed_at AS latestActualAt, 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_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='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='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='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='PICKED_UP' THEN f.actual_at END) AS pickedUpAt,
MAX(CASE WHEN f.standard_node='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt MAX(CASE WHEN f.standard_node='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt
FROM fl_carrier_schedule_query q FROM fl_container_booking b
JOIN ( JOIN fl_carrier_schedule_node_fact current ON current.id=(
SELECT container_booking_id,MAX(id) AS query_id SELECT f2.id FROM fl_carrier_schedule_node_fact f2
FROM fl_carrier_schedule_query WHERE f2.container_booking_id=b.id AND f2.actual_at IS NOT NULL
WHERE container_booking_id IN ORDER BY f2.observed_at DESC,f2.id DESC LIMIT 1)
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach> LEFT JOIN fl_carrier_schedule_query q ON q.id=current.query_id
GROUP BY container_booking_id LEFT JOIN fl_carrier_schedule_node_fact f ON f.container_booking_id=b.id AND f.actual_at IS NOT NULL
) latest ON latest.query_id=q.id WHERE b.id IN
LEFT JOIN fl_carrier_schedule_node_fact f ON f.query_id=q.id AND f.actual_at IS NOT NULL <foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
GROUP BY q.container_booking_id,q.id,q.version,q.status,q.latest_standard_node,q.latest_node_observed_at GROUP BY b.id,q.id,q.version,q.status,current.standard_node,current.actual_at
ORDER BY q.container_booking_id ORDER BY b.id
</script> </script>
""") """)
List<ContainerActualFacts> selectContainerActualFacts( List<ContainerActualFacts> selectContainerActualFacts(
@@ -79,6 +79,12 @@ public class CarrierScheduleQueryRepository {
.eq(CarrierScheduleNodeFactEntity::getQueryId, queryId) .eq(CarrierScheduleNodeFactEntity::getQueryId, queryId)
.eq(CarrierScheduleNodeFactEntity::getEventKey, eventKey)); .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) { public boolean hasActualNode(long queryId, String standardNode) {
return nodes.selectCount(new LambdaQueryWrapper<CarrierScheduleNodeFactEntity>() return nodes.selectCount(new LambdaQueryWrapper<CarrierScheduleNodeFactEntity>()
.eq(CarrierScheduleNodeFactEntity::getQueryId, queryId) .eq(CarrierScheduleNodeFactEntity::getQueryId, queryId)
@@ -42,28 +42,40 @@ public class CarrierScheduleNodeOutboxService {
|| !normalizedContainerNo.equals(query.getTrackingNo())) { || !normalizedContainerNo.equals(query.getTrackingNo())) {
throw new BizException(ResultCode.CONFLICT, "船节点任务跟踪柜号与跨区事件柜号不一致"); 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); ZoneId zone = ZoneId.of(scheduleZone);
var payload = new FirstLegContainerShippingNodeRecordedV1( var payload = new FirstLegContainerShippingNodeRecordedV1(
string(query.getId()), string(query.getOceanBookingId()), string(queryId), string(oceanBookingId), string(containerBookingId), normalizedContainerNo,
string(query.getContainerBookingId()), normalizedContainerNo,
fact.getStandardNode(), fact.getNodeSequence(), fact.getStandardNode(), fact.getNodeSequence(),
fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(), fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(),
fact.getLocationCode(), fact.getLocationName(), fact.getProviderCode(), fact.getProviderEventId(), fact.getLocationCode(), fact.getLocationName(), fact.getProviderCode(), fact.getProviderEventId(),
fact.getProviderNodeCode(), fact.getSourceReference(), fact.getRawPayloadHash()); fact.getProviderNodeCode(), fact.getSourceReference(), fact.getRawPayloadHash());
JsonNode payloadNode = objectMapper.valueToTree(payload); JsonNode payloadNode = objectMapper.valueToTree(payload);
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
long aggregateVersion = Math.max(0, query.getVersion() == null ? 0 : query.getVersion()); long aggregateVersion = Math.max(0, version == null ? 0 : version);
String eventKey = "CARRIER_NODE:" + query.getId() + ':' + fact.getId();
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1, EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1,
fact.getActualAt().atZone(zone).toInstant(), RegionCode.CN, RegionCode.OVERSEAS, 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, aggregateVersion, aggregateVersion + 1, EventIdentifiers.newUuid(), eventKey, null,
payloadNode, payloadHash).requireValid(); payloadNode, payloadHash).requireValid();
InboundOutboxEntity row = new InboundOutboxEntity(); InboundOutboxEntity row = new InboundOutboxEntity();
row.setId(IdWorker.getId()); row.setTenantId(0L); 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.setEventType(envelope.eventType()); row.setEventKey(eventKey); row.setEventVersion(1);
row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash); row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash);
row.setPayloadJson(json(envelope)); row.setPublishStatus("PENDING"); row.setRetryCount(0); row.setPayloadJson(json(envelope)); row.setPublishStatus("PENDING"); row.setRetryCount(0);
@@ -8,6 +8,8 @@ import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; 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.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
@@ -48,6 +50,7 @@ import java.security.MessageDigest;
import java.time.Clock; import java.time.Clock;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.Comparator;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
@@ -101,15 +104,17 @@ public class CarrierScheduleQueryService {
/** Idempotently starts the canonical tracking task after domestic outbound confirmation. */ /** Idempotently starts the canonical tracking task after domestic outbound confirmation. */
@Transactional @Transactional
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(long customerId, long oceanBookingId, public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId,
long containerBookingId, long containerBookingId,
long operatorId, String operatorName) { long operatorId, String operatorName) {
CarrierScheduleQueryEntity existing = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId);
OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId); OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId);
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId); ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在"); 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 trackingType = "CONTAINER_NO";
String trackingNo = normalizedContainerNo(container); String trackingNo = normalizedContainerNo(container);
if (existing != null) { if (existing != null) {
@@ -120,7 +125,7 @@ public class CarrierScheduleQueryService {
LocalDateTime now = now(); LocalDateTime now = now();
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId()); 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.setContainerBookingId(containerBookingId); value.setCarrierCode(strategy.carrierCode());
value.setTrackingType(trackingType); value.setTrackingNo(trackingNo); value.setTrackingType(trackingType); value.setTrackingNo(trackingNo);
value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode()); value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode());
@@ -136,7 +141,7 @@ public class CarrierScheduleQueryService {
try { try {
try { repository.insert(value); } try { repository.insert(value); }
catch (DuplicateKeyException exception) { catch (DuplicateKeyException exception) {
CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId, CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(scopeCustomerId, oceanBookingId,
containerBookingId); containerBookingId);
if (concurrent != null) { if (concurrent != null) {
requireFrozenTrackingIdentity(concurrent, trackingNo); requireFrozenTrackingIdentity(concurrent, trackingNo);
@@ -145,7 +150,7 @@ public class CarrierScheduleQueryService {
throw exception; throw exception;
} }
persistenceStage = "NODE_FACT"; 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", CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
sha256("OUTBOUND_CONFIRM|" + containerBookingId)); sha256("OUTBOUND_CONFIRM|" + containerBookingId));
if (departed != null) { if (departed != null) {
@@ -176,7 +181,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleQueryEntity value = requiredLocked(id); CarrierScheduleQueryEntity value = requiredLocked(id);
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
CarrierScheduleQueryEntity before = copy(value); CarrierScheduleQueryEntity before = copy(value);
CarrierScheduleNodeFactEntity fact = addNode(id, request.providerCode(), request.providerEventId(), CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(),
request.providerNodeCode(), node, request.providerNodeCode(), node,
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(), request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
request.locationName(), request.sourceReference(), request.rawPayloadHash()); request.locationName(), request.sourceReference(), request.rawPayloadHash());
@@ -217,10 +222,9 @@ public class CarrierScheduleQueryService {
} }
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion()); CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
requireManualActualAdvance(value, node, request.actualAt());
CarrierScheduleQueryEntity before = copy(value); CarrierScheduleQueryEntity before = copy(value);
String providerEventId = sha256("MANUAL|" + id + '|' + key); 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.observedAt(),
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(), request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
"MANUAL:" + operatorId, hash(operation, id, request)); "MANUAL:" + operatorId, hash(operation, id, request));
@@ -239,13 +243,42 @@ public class CarrierScheduleQueryService {
return complete(id, operatorId, key, detail(after)); return complete(id, operatorId, key, detail(after));
} }
private void requireManualActualAdvance(CarrierScheduleQueryEntity value, CarrierScheduleNode node, public ContainerNodeDetail containerNodeDetail(long containerBookingId) {
LocalDateTime actualAt) { if (portSchedules.containerBooking(containerBookingId) == null) {
if (actualAt == null || value.getLatestStandardNode() == null) return; throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
CarrierScheduleNode current = CarrierScheduleNode.require(value.getLatestStandardNode());
if (node.sequence() <= current.sequence()) {
throw conflict("人工实际节点必须严格晚于当前最新实际节点");
} }
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, private boolean advanceActualNode(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
@@ -424,7 +457,8 @@ public class CarrierScheduleQueryService {
private void validateLinks(long customerId, long oceanBookingId, Long containerBookingId) { private void validateLinks(long customerId, long oceanBookingId, Long containerBookingId) {
OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId); OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId);
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "关联订舱不存在"); 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 (containerBookingId != null) {
if (portSchedules.containerBooking(containerBookingId) == null) { if (portSchedules.containerBooking(containerBookingId) == null) {
throw new BizException(ResultCode.NOT_FOUND, "关联柜次不存在"); throw new BizException(ResultCode.NOT_FOUND, "关联柜次不存在");
@@ -490,13 +524,23 @@ public class CarrierScheduleQueryService {
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt()); 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, String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt, CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName, LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) { 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(); 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.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT)); fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT));
fact.setProviderEventId(providerEventId.trim()); fact.setProviderEventId(providerEventId.trim());
@@ -505,7 +549,7 @@ public class CarrierScheduleQueryService {
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName)); fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash); fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
if (repository.insertIfAbsent(fact)) return fact; 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)) { if (existing != null && !Objects.equals(existing.getRawPayloadHash(), rawPayloadHash)) {
throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有节点事实"); throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有节点事实");
} }
@@ -55,6 +55,19 @@ public class FbaPricingRepository {
public void insertRule(CustomerChargeRuleEntity rule) { rules.insert(rule); } 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) { public int clearDefaultRule(long tenantId, long customerId, long operatorId) {
return rules.update(null, new LambdaUpdateWrapper<CustomerChargeRuleEntity>() return rules.update(null, new LambdaUpdateWrapper<CustomerChargeRuleEntity>()
.eq(CustomerChargeRuleEntity::getTenantId, tenantId) .eq(CustomerChargeRuleEntity::getTenantId, tenantId)
@@ -285,22 +298,22 @@ public class FbaPricingRepository {
.set(InboundFeeEntity::getUpdatedAt, LocalDateTime.now())); .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) { String chargeUnit, java.math.BigDecimal chargeableQuantity) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>() LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId) .eq(InboundPricingEntity::getTenantId, tenantId)
.eq(InboundPricingEntity::getId, inboundId) .eq(InboundPricingEntity::getId, inboundId)
.eq(InboundPricingEntity::getProcessStatus, "WAIT_SPLIT") .eq(InboundPricingEntity::getProcessStatus, processStatus)
.set(InboundPricingEntity::getFeeStatus, feeStatus); .set(InboundPricingEntity::getFeeStatus, feeStatus);
if ("KG".equals(chargeUnit)) { applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeUnit, chargeableQuantity);
update.set(InboundPricingEntity::getChargeableWeightKg, chargeableQuantity);
} else {
update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeableQuantity);
}
return inbounds.update(null, update); return inbounds.update(null, update);
} }
public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode, public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode,
java.math.BigDecimal selectedWeightKg,
java.math.BigDecimal selectedVolumeCbm,
java.math.BigDecimal chargeable) { java.math.BigDecimal chargeable) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>() LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId) .eq(InboundPricingEntity::getTenantId, tenantId)
@@ -308,15 +321,17 @@ public class FbaPricingRepository {
.eq(InboundPricingEntity::getProcessStatus, processStatus) .eq(InboundPricingEntity::getProcessStatus, processStatus)
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED", "SENT")) .in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED", "SENT"))
.set(InboundPricingEntity::getFeeStatus, "CALCULATED"); .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"); update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING");
} }
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable); applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeMode, chargeable);
else update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeable);
return inbounds.update(null, update); return inbounds.update(null, update);
} }
public int markInboundContainerFeeRecalculated(long tenantId, long inboundId, String processStatus, public int markInboundContainerFeeRecalculated(long tenantId, long inboundId, String processStatus,
java.math.BigDecimal selectedWeightKg,
java.math.BigDecimal selectedVolumeCbm,
String chargeMode, java.math.BigDecimal chargeable) { String chargeMode, java.math.BigDecimal chargeable) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>() LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId) .eq(InboundPricingEntity::getTenantId, tenantId)
@@ -324,11 +339,21 @@ public class FbaPricingRepository {
.eq(InboundPricingEntity::getProcessStatus, processStatus) .eq(InboundPricingEntity::getProcessStatus, processStatus)
.in(InboundPricingEntity::getFeeStatus, List.of("CALCULATED", "CONFIRMED")) .in(InboundPricingEntity::getFeeStatus, List.of("CALCULATED", "CONFIRMED"))
.set(InboundPricingEntity::getFeeStatus, "CALCULATED"); .set(InboundPricingEntity::getFeeStatus, "CALCULATED");
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable); applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeMode, chargeable);
else update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeable);
return inbounds.update(null, update); 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) { public int confirmFee(long tenantId, long feeId, long operatorId, LocalDateTime now) {
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>() return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId).eq(InboundFeeEntity::getId, feeId) .eq(InboundFeeEntity::getTenantId, tenantId).eq(InboundFeeEntity::getId, feeId)
@@ -71,6 +71,14 @@ public class InboundMeasurementRepository {
.eq(InboundMeasurementEntity::getId, measurementId)); .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) { public InboundMeasurementEntity findLatestMeasurement(long tenantId, long inboundId) {
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>() return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
.eq(InboundMeasurementEntity::getTenantId, tenantId) .eq(InboundMeasurementEntity::getTenantId, tenantId)
@@ -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();
}
}
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.RuleResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.RuleUpdateRequest; import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.RuleUpdateRequest;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest; import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest;
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity; import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository; import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -32,6 +33,8 @@ public class CustomerDimensionConfigService {
@Resource @Resource
private CustomerDimensionConfigRepository repository; private CustomerDimensionConfigRepository repository;
@Resource
private CustomerChargeRuleInitializationService chargeRuleInitialization;
public GroupResponse get(long tenantId) { public GroupResponse get(long tenantId) {
return response(tenantId, requireCompleteRules(repository.findAll(tenantId))); return response(tenantId, requireCompleteRules(repository.findAll(tenantId)));
@@ -78,18 +81,26 @@ public class CustomerDimensionConfigService {
} }
@Transactional @Transactional
public InitializeResponse initializeDefault(long tenantId, long operatorId, String initializationKey) { public InitializeResponse initializeDefault(long tenantId, long operatorId, String initializationKey,
String customerName) {
requireTenant(tenantId); requireTenant(tenantId);
String normalizedKey = requireInitializationKey(initializationKey); String normalizedKey = requireInitializationKey(initializationKey);
List<CustomerDimensionConfigEntity> existing = repository.findAll(tenantId); List<CustomerDimensionConfigEntity> existing = repository.findAll(tenantId);
if (!existing.isEmpty()) { if (!existing.isEmpty()) {
List<CustomerDimensionConfigEntity> complete = requireCompleteRules(existing); 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); repository.insertDefaults(defaultRows(tenantId, normalizedKey), operatorId);
List<CustomerDimensionConfigEntity> persisted = requireCompleteRules(repository.findAll(tenantId)); 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 @Transactional
@@ -99,6 +110,12 @@ public class CustomerDimensionConfigService {
List<CustomerDimensionConfigEntity> locked = List<CustomerDimensionConfigEntity> locked =
repository.findDefaultsForCompensation(tenantId, normalizedKey); repository.findDefaultsForCompensation(tenantId, normalizedKey);
if (!isUnchangedDefaultGroup(locked, normalizedKey)) return; 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) { if (repository.deleteUnchangedDefaults(tenantId, normalizedKey) != CustomerDimensionBoxType.values().length) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户箱规默认配置补偿删除失败"); throw new BizException(ResultCode.INTERNAL_ERROR, "客户箱规默认配置补偿删除失败");
} }
@@ -206,11 +223,16 @@ public class CustomerDimensionConfigService {
return true; 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, private static InitializeResponse initializationResponse(List<CustomerDimensionConfigEntity> values,
String initializationKey) { CustomerChargeRuleEntity chargeRule,
boolean ownedByKey = values.stream() boolean ownedByKey) {
.allMatch(value -> initializationKey.equals(value.getInitializationKey())); return new InitializeResponse(values.get(0).getId(),
return new InitializeResponse(values.get(0).getId(), ownedByKey); chargeRule == null ? null : chargeRule.getId(), ownedByKey);
} }
private static GroupResponse response(long tenantId, List<CustomerDimensionConfigEntity> values) { 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 ADJUST_TOLERANCE_PERCENT = new BigDecimal("10.00");
private static final BigDecimal ONE_HUNDRED = new BigDecimal("100"); private static final BigDecimal ONE_HUNDRED = new BigDecimal("100");
private static final long AUTOMATIC_CONFIRM_OPERATOR_ID = 0L; 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"; private static final String CONTAINER_RECALCULATE_OPERATION = "CONTAINER_FEE_RECALCULATE";
@Resource @Resource
private FbaPricingRepository repository; private FbaPricingRepository repository;
@@ -99,8 +98,8 @@ public class FbaPricingService {
if (published == null || !"SENT".equals(published.getFeeStatus())) { if (published == null || !"SENT".equals(published.getFeeStatus())) {
throw conflict("费用状态已变化,请刷新后重试"); throw conflict("费用状态已变化,请刷新后重试");
} }
if (published.getCustomerConfirmedAt() != null) { if (feeRevisionLocked(inbound, published)) {
throw conflict("客户已确认该费用,不允许再修改或重新发送"); throw conflict("客户已确认且该票已装柜完成,不允许再修改或重新发送费用");
} }
} }
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId); var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
@@ -167,7 +166,7 @@ public class FbaPricingService {
repository.insertItem(baseItem(tenantId, fee, rate, chargeable, subtotal, now)); repository.insertItem(baseItem(tenantId, fee, rate, chargeable, subtotal, now));
if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, fee, additional, now)); if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, fee, additional, now));
if (repository.markInboundCalculated(tenantId, inboundId, inbound.getProcessStatus(), if (repository.markInboundCalculated(tenantId, inboundId, inbound.getProcessStatus(),
estimate.chargeUnit(), chargeable) != 1) { estimate.chargeUnit(), weight, volume, chargeable) != 1) {
throw conflict("入库单状态已变化,费用试算未保存"); throw conflict("入库单状态已变化,费用试算未保存");
} }
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now); feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
@@ -343,7 +342,8 @@ public class FbaPricingService {
recalculated.getSubtotalAmount(), now)); recalculated.getSubtotalAmount(), now));
if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, recalculated, additional, now)); if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, recalculated, additional, now));
if (repository.markInboundContainerFeeRecalculated(tenantId, member.inbound().getId(), 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("柜内入库单状态已变化,不能重算阶梯费用"); throw conflict("柜内入库单状态已变化,不能重算阶梯费用");
} }
feeDetails.materializeCalculatedFreight(tenantId, member.inbound().getId(), recalculated.getId(), feeDetails.materializeCalculatedFreight(tenantId, member.inbound().getId(), recalculated.getId(),
@@ -390,8 +390,8 @@ public class FbaPricingService {
public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId, public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId,
String idempotencyKey) { String idempotencyKey) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId); InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!feeEditableBeforeLoading(inbound.getProcessStatus())) { if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
throw conflict("订单已进入装柜流程,不能再修改或发送费用"); throw conflict("当前入库单状态不允许确认费用");
} }
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId); InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
if (current == null) throw conflict("入库单尚无可确认的费用试算"); if (current == null) throw conflict("入库单尚无可确认的费用试算");
@@ -412,8 +412,8 @@ public class FbaPricingService {
public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId, public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId,
String idempotencyKey) { String idempotencyKey) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId); InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!feeEditableBeforeLoading(inbound.getProcessStatus())) { if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
throw conflict("订单已进入装柜流程,不能再修改或发送费用"); throw conflict("当前入库单状态不允许发送费用");
} }
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId); InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
if (current == null) throw conflict("入库单尚无费用快照"); if (current == null) throw conflict("入库单尚无费用快照");
@@ -445,21 +445,6 @@ public class FbaPricingService {
return response(tenantId, fee.getId(), null); 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 @Transactional
public FeeSnapshotResponse confirmByCustomer(long tenantId, long operatorId, String operatorName, public FeeSnapshotResponse confirmByCustomer(long tenantId, long operatorId, String operatorName,
long inboundId, int expectedFeeVersion, long inboundId, int expectedFeeVersion,
@@ -498,15 +483,15 @@ public class FbaPricingService {
AdjustFeeRequest request, String idempotencyKey) { AdjustFeeRequest request, String idempotencyKey) {
InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId); InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
if (!isCompletedInbound(inbound.getProcessStatus())) { if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
throw conflict("只能在待分柜阶段修改初算或已发布费用"); throw conflict("当前入库单状态不允许修改初算或已发布费用");
} }
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId); InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
if (current == null || !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) { if (current == null || !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) {
throw conflict("入库单没有可修改的初算或已发布费用"); throw conflict("入库单没有可修改的初算或已发布费用");
} }
if (current.getCustomerConfirmedAt() != null) { if (feeRevisionLocked(inbound, current)) {
throw conflict("客户已确认该费用,不允许再修改"); throw conflict("客户已确认且该票已装柜完成,不允许再修改费用");
} }
if (!request.expectedFeeVersion().equals(current.getFeeVersion())) { if (!request.expectedFeeVersion().equals(current.getFeeVersion())) {
throw conflict("费用版本已变化,请刷新后重试"); throw conflict("费用版本已变化,请刷新后重试");
@@ -554,6 +539,9 @@ public class FbaPricingService {
adjusted.setConfirmedAt(published ? now : null); adjusted.setConfirmedAt(published ? now : null);
adjusted.setSentBy(published ? operatorId : null); adjusted.setSentBy(published ? operatorId : null);
adjusted.setSentAt(published ? now : null); adjusted.setSentAt(published ? now : null);
adjusted.setCustomerConfirmedBy(null);
adjusted.setCustomerConfirmedByName(null);
adjusted.setCustomerConfirmedAt(null);
adjusted.setCreatedAt(now); adjusted.setCreatedAt(now);
adjusted.setUpdatedAt(now); adjusted.setUpdatedAt(now);
if (repository.supersedeCurrent(tenantId, current.getId(), current.getFeeStatus()) != 1) { if (repository.supersedeCurrent(tenantId, current.getId(), current.getFeeStatus()) != 1) {
@@ -576,8 +564,8 @@ public class FbaPricingService {
extra.setRemark(request.reason().trim()); extra.setRemark(request.reason().trim());
repository.insertItem(extra); repository.insertItem(extra);
} }
if (repository.markInboundAdjusted(tenantId, inboundId, adjusted.getFeeStatus(), if (repository.markInboundAdjusted(tenantId, inboundId, inbound.getProcessStatus(), adjusted.getFeeStatus(),
adjusted.getChargeUnit(), quantity) != 1) { adjusted.getActualWeightKg(), adjusted.getActualVolumeCbm(), adjusted.getChargeUnit(), quantity) != 1) {
throw conflict("入库单状态已变化,费用调整未保存"); throw conflict("入库单状态已变化,费用调整未保存");
} }
if (published) { if (published) {
@@ -594,7 +582,7 @@ public class FbaPricingService {
private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) { private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId); InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!feeEditableBeforeLoading(inbound.getProcessStatus()) if (!feeRevisionStatusSupported(inbound.getProcessStatus())
|| !List.of("UNPRICED", "CALCULATED", "SENT").contains(inbound.getFeeStatus())) { || !List.of("UNPRICED", "CALCULATED", "SENT").contains(inbound.getFeeStatus())) {
throw conflict("当前入库单状态不允许费用试算"); throw conflict("当前入库单状态不允许费用试算");
} }
@@ -613,20 +601,23 @@ public class FbaPricingService {
return inbound; return inbound;
} }
private static boolean isCompletedInbound(String processStatus) {
return "WAIT_SPLIT".equals(processStatus);
}
private static boolean preserveProcessStatusDuringPricing(String 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", 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); .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) { private static PricingDestinationType destinationType(InboundPricingEntity inbound) {
try { try {
return PricingDestinationType.valueOf(inbound.getDestinationType()); return PricingDestinationType.valueOf(inbound.getDestinationType());
@@ -92,6 +92,27 @@ public class InboundCartonLabelPrintService {
return ResponseEntity.status(response.getStatusCode()).headers(headers).body(response.getBody()); 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箱数和托盘二维码。 */ /** 一件代发托盘标签:托盘号、入仓码、SKU箱数和托盘二维码。 */
public ResponseEntity<byte[]> generateDropshipPallet(Pallet pallet, String requestId) { public ResponseEntity<byte[]> generateDropshipPallet(Pallet pallet, String requestId) {
if (pallet == null || !StringUtils.hasText(pallet.palletNo()) || pallet.items().isEmpty()) { 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 com.baiyue.firstleg.repository.InboundCartonLabelRepository;
import feign.FeignException; import feign.FeignException;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -117,7 +118,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
throw new BizException(ResultCode.INTERNAL_ERROR, "预入库单补建后未能读取"); throw new BizException(ResultCode.INTERNAL_ERROR, "预入库单补建后未能读取");
} }
return prepared; return prepared;
} catch (FeignException exception) { } catch (FeignException | NoFallbackAvailableException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法补建预入库单"); throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法补建预入库单");
} }
} }
@@ -181,7 +182,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
.thenComparing(OrderCargoClient.CargoLabelBinding::cargoId, .thenComparing(OrderCargoClient.CargoLabelBinding::cargoId,
java.util.Comparator.nullsLast(Long::compareTo))) java.util.Comparator.nullsLast(Long::compareTo)))
.toList(); .toList();
} catch (FeignException exception) { } catch (FeignException | NoFallbackAvailableException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法生成唛头"); 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.entity.MeasureReviewRecordEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository; import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository; import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@@ -61,6 +62,8 @@ public class InboundMeasurementService {
@Resource @Resource
private MeasurementBatchService batchService; private MeasurementBatchService batchService;
@Resource @Resource
private FbaPricingRepository pricingRepository;
@Resource
private FbaMasterDataService fbaMasterDataService; private FbaMasterDataService fbaMasterDataService;
@Resource @Resource
private OrderCargoClient orderCargoClient; private OrderCargoClient orderCargoClient;
@@ -162,6 +165,68 @@ public class InboundMeasurementService {
return new InboundMeasurementResponses.DraftSaved(saved.getId(), saved.getVersion(), saved.getStatus()); 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 @Transactional
public InboundMeasurementResponses.MeasurementSubmitted submit( public InboundMeasurementResponses.MeasurementSubmitted submit(
long tenantId, long inboundId, long operatorId, long tenantId, long inboundId, long operatorId,
@@ -180,6 +245,10 @@ public class InboundMeasurementService {
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId()); List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId); List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
ConfirmedWeightTotals confirmedTotals = confirmedWeightTotals(weights); ConfirmedWeightTotals confirmedTotals = confirmedWeightTotals(weights);
if (confirmedTotals.palletCount() == 0) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"请至少上传并确认一张称重图片后再提交测量审核");
}
requireConfirmedPackageTotal(inbound, items); requireConfirmedPackageTotal(inbound, items);
measurement.setStatus(STATUS_SUBMITTED); measurement.setStatus(STATUS_SUBMITTED);
@@ -246,7 +246,7 @@ public class LoadingPickService {
public InventorySnapshot inventorySnapshot(long taskId, long warehouseId) { public InventorySnapshot inventorySnapshot(long taskId, long warehouseId) {
LoadingPickTaskEntity task = requiredTask(taskId); LoadingPickTaskEntity task = requiredTask(taskId);
List<LoadingPickItemEntity> items = warehouseScopedItems(task, warehouseId); List<LoadingPickItemEntity> items = taskItems(task, warehouseId);
return buildInventorySnapshot(task, warehouseId, items); return buildInventorySnapshot(task, warehouseId, items);
} }
@@ -326,10 +326,7 @@ public class LoadingPickService {
assertReplay(replay, taskId, itemId, requestHash); assertReplay(replay, taskId, itemId, requestHash);
return view(task, request.warehouseId()); return view(task, request.warehouseId());
} }
LoadingPickItemEntity item = repository.item(taskId, itemId, request.warehouseId()); LoadingPickItemEntity item = requireTaskItem(taskId, itemId, request.warehouseId());
if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在");
}
requirePackageUnit(item); requirePackageUnit(item);
if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) { if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) {
throw conflict("拣货数量超过计划箱数或明细版本已变化"); throw conflict("拣货数量超过计划箱数或明细版本已变化");
@@ -404,7 +401,7 @@ public class LoadingPickService {
assertReplay(replay, taskId, itemId, requestHash); assertReplay(replay, taskId, itemId, requestHash);
return view(task, request.warehouseId()); return view(task, request.warehouseId());
} }
LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId()); LoadingPickItemEntity item = requireTaskItem(taskId, itemId, request.warehouseId());
requirePackageUnit(item); requirePackageUnit(item);
if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(), if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(),
request.warehouseId()) != 1) { request.warehouseId()) != 1) {
@@ -437,7 +434,7 @@ public class LoadingPickService {
assertReplay(replay, taskId, itemId, requestHash); assertReplay(replay, taskId, itemId, requestHash);
return view(task, request.warehouseId()); return view(task, request.warehouseId());
} }
LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId()); LoadingPickItemEntity item = requireTaskItem(taskId, itemId, request.warehouseId());
requirePackageUnit(item); requirePackageUnit(item);
if (repository.hasPickedOrPartialItem(taskId, item.getInboundId())) { if (repository.hasPickedOrPartialItem(taskId, item.getInboundId())) {
throw conflict("同一入库单已存在已拣或部分拣货明细,不能跳拣并恢复智能装柜候选"); throw conflict("同一入库单已存在已拣或部分拣货明细,不能跳拣并恢复智能装柜候选");
@@ -940,10 +937,10 @@ public class LoadingPickService {
return task; 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); LoadingPickItemEntity item = repository.item(taskId, itemId, warehouseId);
if (item == null) { if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在或不属于当前仓库"); throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在或不属于当前任务");
} }
return item; return item;
} }
@@ -953,16 +950,12 @@ public class LoadingPickService {
if (booking == null || !Objects.equals(task.getContainerBookingId(), booking.getId())) { if (booking == null || !Objects.equals(task.getContainerBookingId(), booking.getId())) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或柜次已非有效存续记录"); 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 List<LoadingPickItemEntity> source = warehouseId == null
? repository.items(task.getId()) : repository.items(task.getId(), warehouseId); ? repository.items(task.getId()) : repository.items(task.getId(), warehouseId);
Map<Long, List<CartonLabelRow>> cartonsByItem = repository.cartonLabels(task.getId()).stream() Map<Long, List<CartonLabelRow>> cartonsByItem = repository.cartonLabels(task.getId()).stream()
.collect(Collectors.groupingBy(CartonLabelRow::getPickItemId)); .collect(Collectors.groupingBy(CartonLabelRow::getPickItemId));
Map<Long, Long> tenantByInbound = tenantByInbound(task.getId()); Map<Long, Long> tenantByInbound = tenantByInbound(task.getId());
Map<Long, List<InventoryResource>> inventoryByInbound = inventoryByInbound( Map<Long, List<InventoryResource>> inventoryByInbound = inventoryByInbound(source, tenantByInbound);
booking.getWarehouseId(), source, tenantByInbound);
List<PickItem> items = source.stream().map(item -> { List<PickItem> items = source.stream().map(item -> {
List<CartonLabelRow> cartons = cartonsByItem.getOrDefault(item.getId(), List.of()); List<CartonLabelRow> cartons = cartonsByItem.getOrDefault(item.getId(), List.of());
CartonLabelRow first = cartons.isEmpty() ? null : cartons.get(0); CartonLabelRow first = cartons.isEmpty() ? null : cartons.get(0);
@@ -1011,11 +1004,11 @@ public class LoadingPickService {
private List<BatchLine> validateBatchLines(LoadingPickTaskEntity task, BatchRequest request) { private List<BatchLine> validateBatchLines(LoadingPickTaskEntity task, BatchRequest request) {
long taskId = task.getId(); long taskId = task.getId();
List<LoadingPickItemEntity> allItems = repository.items(taskId); List<LoadingPickItemEntity> allItems = repository.items(taskId);
List<LoadingPickItemEntity> scopedItems = repository.items(taskId, request.warehouseId()); List<LoadingPickItemEntity> taskItems = repository.items(taskId, request.warehouseId());
if (allItems.isEmpty() || scopedItems.size() != allItems.size()) { if (allItems.isEmpty() || taskItems.size() != allItems.size()) {
throw conflict("PDA任务柜次、仓库或客户归属不一致"); 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)); LoadingPickItemEntity::getId, item -> item));
Set<Long> unresolvedIds = allItems.stream().filter(item -> !isResolved(item)) Set<Long> unresolvedIds = allItems.stream().filter(item -> !isResolved(item))
.map(LoadingPickItemEntity::getId).collect(Collectors.toSet()); .map(LoadingPickItemEntity::getId).collect(Collectors.toSet());
@@ -1033,7 +1026,7 @@ public class LoadingPickService {
LoadingPickItemEntity item = itemsById.get(requestItem.itemId()); LoadingPickItemEntity item = itemsById.get(requestItem.itemId());
if (item == null || item.getInboundId() == null || item.getLoadingPlanItemId() == null if (item == null || item.getInboundId() == null || item.getLoadingPlanItemId() == null
|| item.getInboundDetailId() == null) { || item.getInboundDetailId() == null) {
throw conflict("PDA明细不属于当前任务、柜次、仓库或客户"); throw conflict("PDA明细不属于当前任务、柜次或客户");
} }
if (!"SKIP".equals(requestItem.action())) { if (!"SKIP".equals(requestItem.action())) {
inboundHasWork.put(item.getInboundId(), true); inboundHasWork.put(item.getInboundId(), true);
@@ -1096,7 +1089,7 @@ public class LoadingPickService {
} }
result.add(new BatchLine(item, requestItem, action, request.warehouseId())); 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())); result.sort(Comparator.comparing(line -> line.item().getId()));
return result; 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> allItems = repository.items(task.getId());
List<LoadingPickItemEntity> scopedItems = repository.items(task.getId(), warehouseId); List<LoadingPickItemEntity> taskItems = repository.items(task.getId(), warehouseId);
if (allItems.isEmpty() || scopedItems.size() != allItems.size()) { if (allItems.isEmpty() || taskItems.size() != allItems.size()) {
throw conflict("PDA任务柜次、仓库或客户归属不一致"); throw conflict("PDA任务柜次或客户归属不一致");
} }
return scopedItems; return taskItems;
} }
private InventorySnapshot buildInventorySnapshot(LoadingPickTaskEntity task, long warehouseId, private InventorySnapshot buildInventorySnapshot(LoadingPickTaskEntity task, long warehouseId,
List<LoadingPickItemEntity> items) { List<LoadingPickItemEntity> items) {
Map<Long, List<InventoryResource>> inventoryByInbound = inventoryByInbound( Map<Long, List<InventoryResource>> inventoryByInbound = inventoryByInbound(
warehouseId, items, tenantByInbound(task.getId())); items, tenantByInbound(task.getId()));
List<InboundInventory> inbounds = items.stream().map(LoadingPickItemEntity::getInboundId) List<InboundInventory> inbounds = items.stream().map(LoadingPickItemEntity::getInboundId)
.filter(Objects::nonNull).distinct().sorted().map(inboundId -> { .filter(Objects::nonNull).distinct().sorted().map(inboundId -> {
List<InventoryResource> resources = inventoryByInbound.getOrDefault(inboundId, List.of()); List<InventoryResource> resources = inventoryByInbound.getOrDefault(inboundId, List.of());
@@ -1160,8 +1153,7 @@ public class LoadingPickService {
})); }));
} }
private Map<Long, List<InventoryResource>> inventoryByInbound(long warehouseId, private Map<Long, List<InventoryResource>> inventoryByInbound(List<LoadingPickItemEntity> items,
List<LoadingPickItemEntity> items,
Map<Long, Long> tenantByInbound) { Map<Long, Long> tenantByInbound) {
Map<Long, List<InventoryResource>> result = new HashMap<>(); Map<Long, List<InventoryResource>> result = new HashMap<>();
for (Long inboundId : items.stream().map(LoadingPickItemEntity::getInboundId) for (Long inboundId : items.stream().map(LoadingPickItemEntity::getInboundId)
@@ -1169,7 +1161,6 @@ public class LoadingPickService {
Long tenantId = tenantByInbound.get(inboundId); Long tenantId = tenantByInbound.get(inboundId);
if (tenantId == null) throw conflict("PDA任务缺少入库单客户归属"); if (tenantId == null) throw conflict("PDA任务缺少入库单客户归属");
List<InventoryResource> resources = palletAllocations.list(tenantId, inboundId).stream() List<InventoryResource> resources = palletAllocations.list(tenantId, inboundId).stream()
.filter(resource -> Objects.equals(resource.getWarehouseId(), warehouseId))
.filter(resource -> "CONFIRMED".equals(resource.getAllocationStatus())) .filter(resource -> "CONFIRMED".equals(resource.getAllocationStatus()))
.sorted(Comparator.comparing(InboundPalletAllocationEntity::getBlockId, .sorted(Comparator.comparing(InboundPalletAllocationEntity::getBlockId,
Comparator.nullsLast(Long::compareTo)) Comparator.nullsLast(Long::compareTo))
@@ -1222,7 +1213,7 @@ public class LoadingPickService {
private void validateInventorySnapshot(LoadingPickTaskEntity task, BatchRequest request, private void validateInventorySnapshot(LoadingPickTaskEntity task, BatchRequest request,
List<BatchLine> lines) { List<BatchLine> lines) {
List<LoadingPickItemEntity> allItems = warehouseScopedItems(task, request.warehouseId()); List<LoadingPickItemEntity> allItems = taskItems(task, request.warehouseId());
InventorySnapshot current = buildInventorySnapshot(task, request.warehouseId(), allItems); InventorySnapshot current = buildInventorySnapshot(task, request.warehouseId(), allItems);
if (!current.snapshotToken().equals(request.inventorySnapshotToken().trim())) { if (!current.snapshotToken().equals(request.inventorySnapshotToken().trim())) {
throw conflict("库存已变化,请刷新后重试"); throw conflict("库存已变化,请刷新后重试");
@@ -93,6 +93,87 @@ public class MeasurementBatchService {
return measurement; 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();
}
}
/** /**
* 人工补录会整批替换可编辑测量项,但不能抹掉同一项已冻结的扫码身份与设备取证。 * 人工补录会整批替换可编辑测量项,但不能抹掉同一项已冻结的扫码身份与设备取证。
* 只按入库货物明细和明细号精确匹配,避免把一个箱码猜测性地复制给其他测量项。 * 只按入库货物明细和明细号精确匹配,避免把一个箱码猜测性地复制给其他测量项。
@@ -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, 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 b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
FROM fl_container_booking b 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); 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 b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
FROM fl_container_booking b FROM fl_container_booking b
WHERE b.id=#{containerBookingId} 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); Context context(@Param("containerBookingId") long containerBookingId);
@@ -37,6 +44,9 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
FROM fl_container_booking b FROM fl_container_booking b
WHERE b.id IN WHERE b.id IN
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach> <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 ORDER BY b.id
</script> </script>
""") """)
@@ -45,8 +45,10 @@ public class ContainerWorkbenchService {
public WorkbenchItem overseasDetail(long id, Set<String> permissions) { public WorkbenchItem overseasDetail(long id, Set<String> permissions) {
ContainerBookingEntity value = required(id); ContainerBookingEntity value = required(id);
if (!repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国外工作台货柜不存在"); if (!repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国外工作台货柜不存在");
View drayage = terminalDrayages.authorize(terminalDrayages.detail(id), permissions); View drayage = terminalDrayages.authorize(terminalDrayages.views(List.of(id)), permissions).stream()
return item("OVERSEAS", value, drayage.actualFacts(), drayage, permissions); .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) { private PageResult<WorkbenchItem> page(String workbench, Query query, Set<String> permissions) {
@@ -5,7 +5,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode; import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; 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.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest; import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest;
@@ -123,7 +123,7 @@ public class TerminalDrayageService {
requireVersion(value, request.expectedVersion()); requireVersion(value, request.expectedVersion());
if (value.getFleetSupplierId() == null) throw conflict("请先选择有效码头车队"); if (value.getFleetSupplierId() == null) throw conflict("请先选择有效码头车队");
if (actualPickupAt(context, value) != 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())) { && request.pickupAppointmentAt().isBefore(context.facts().arrivedDestinationPortAt())) {
throw badRequest("预约提柜时间不能早于实际到港时间"); throw badRequest("预约提柜时间不能早于实际到港时间");
} }
@@ -149,7 +149,8 @@ public class TerminalDrayageService {
throw conflict("选择码头车队并预约提柜后才能确认实际提柜"); throw conflict("选择码头车队并预约提柜后才能确认实际提柜");
} }
if (actualPickupAt(context, value) != null) 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("实际提柜时间不能早于当前实际船节点时间"); throw badRequest("实际提柜时间不能早于当前实际船节点时间");
} }
advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key, 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, private void advanceNode(WorkContext context, CarrierScheduleNode target, LocalDateTime actualAt,
String action, String key, String reason, long operatorId, String operatorName) { String action, String key, String reason, long operatorId, String operatorName) {
if (context.facts().queryId() == null || context.facts().queryVersion() == null) { CarrierScheduleNode current = currentNode(context);
throw conflict("货柜尚未建立船节点跟踪任务");
}
CarrierScheduleNode current = node(context.facts().latestActualNode());
if (current != null && current.sequence() >= target.sequence()) return; 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()), "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() : "码头拖车操作同步")); actualAt, null, null, StringUtils.hasText(reason) ? reason.trim() : "码头拖车操作同步"));
} }
@@ -292,7 +290,7 @@ public class TerminalDrayageService {
List<String> result = new ArrayList<>(); List<String> result = new ArrayList<>();
if (value != null && value.getFleetSupplierId() != null && actualPickupAt(context, value) == null if (value != null && value.getFleetSupplierId() != null && actualPickupAt(context, value) == null
&& actualEmptyReturnAt(context, value) == null && context.base().unloadingCompletedAt() == null) result.add("SEND_PICKUP_EMAIL"); && 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() if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
&& current.sequence() < CarrierScheduleNode.PICKED_UP.sequence() && current.sequence() < CarrierScheduleNode.PICKED_UP.sequence()
&& actualPickupAt(context, value) == null) { && actualPickupAt(context, value) == null) {
@@ -312,7 +310,7 @@ public class TerminalDrayageService {
if (context.base().unloadingCompletedAt() != null) return "UNLOADED"; if (context.base().unloadingCompletedAt() != null) return "UNLOADED";
if (actualPickupAt(context, value) != null) return "PICKED_UP"; if (actualPickupAt(context, value) != null) return "PICKED_UP";
if (value != null && value.getPickupAppointmentAt() != null) return "PICKUP_SCHEDULED"; 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() return current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
? "ARRIVED" : "WAITING_FOR_ARRIVAL"; ? "ARRIVED" : "WAITING_FOR_ARRIVAL";
} }
@@ -322,7 +320,7 @@ public class TerminalDrayageService {
} }
private void requireAtLeast(WorkContext context, CarrierScheduleNode required, String message) { 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); 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; 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) { 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) { private LocalDateTime actualEmptyReturnAt(WorkContext context, TerminalDrayageEntity value) {
return value != null && value.getActualEmptyReturnAt() != null 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) { private WorkContext requiredContext(long id) {
@@ -362,8 +365,7 @@ public class TerminalDrayageService {
} }
private WorkContext context(Context base, ContainerActualFacts facts) { private WorkContext context(Context base, ContainerActualFacts facts) {
if (base == null || !"ACTIVE".equals(base.recordStatus()) || facts == null if (base == null || !"ACTIVE".equals(base.recordStatus())) return null;
|| facts.departedWarehouseAt() == null) return null;
return new WorkContext(base, facts); 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', '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', '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', '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_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', '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"); 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; 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 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'); 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 '生效结束时间', effective_to DATETIME(3) NULL COMMENT '生效结束时间',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态:ACTIVE启用、DISABLED停用', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态:ACTIVE启用、DISABLED停用',
remark VARCHAR(500) NULL COMMENT '规则备注', remark VARCHAR(500) NULL COMMENT '规则备注',
initialization_key VARCHAR(128) NULL COMMENT '客户创建执行器幂等键及补偿归属',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', 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 '乐观锁版本号', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_charge_rule_version (tenant_id, rule_code, rule_version), 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) KEY idx_charge_rule_match (tenant_id, customer_id, business_type, status, effective_from, effective_to)
) ENGINE=InnoDB COMMENT='客户头程计费规则版本'; ) 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_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_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 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 CONSTRAINT chk_carrier_schedule_query_carrier CHECK
(carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')), (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')),
CONSTRAINT chk_carrier_schedule_query_tracking CHECK 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 ( CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
id BIGINT UNSIGNED NOT NULL COMMENT '标准船节点事实ID', 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组成的幂等键', event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键',
provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码', provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码',
provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID', 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;不保存敏感原文', raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
PRIMARY KEY (id), 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_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_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 CONSTRAINT chk_carrier_schedule_node_standard CHECK
(standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT', (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')), 'IN_TRANSIT','ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')),
@@ -34,7 +34,7 @@ paths:
summary: Query carrier schedule tasks summary: Query carrier schedule tasks
x-permission: platform:firstleg:port-schedule-query:list x-permission: platform:firstleg:port-schedule-query:list
parameters: 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: oceanBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}}
- {name: containerBookingId, 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'}} - {name: carrierCode, in: query, schema: {$ref: '#/components/schemas/TaskCarrierCode'}}
@@ -126,7 +126,7 @@ components:
required: [expectedVersion, customerId, oceanBookingId, containerBookingId, carrierCode, trackingType, trackingNo] required: [expectedVersion, customerId, oceanBookingId, containerBookingId, carrierCode, trackingType, trackingNo]
properties: properties:
expectedVersion: {type: integer, minimum: 0, maximum: 0} 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} oceanBookingId: {type: integer, format: int64, minimum: 1}
containerBookingId: {type: integer, format: int64, minimum: 1} containerBookingId: {type: integer, format: int64, minimum: 1}
carrierCode: {$ref: '#/components/schemas/CarrierCode'} carrierCode: {$ref: '#/components/schemas/CarrierCode'}
@@ -123,13 +123,13 @@ components:
schemas: schemas:
LongId: {type: string, pattern: '^[1-9][0-9]*$', description: Long ID序列化为字符串} 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}}} 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'}}}]} 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}}} 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}}} 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}}} 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}}} 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}}} 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}}} 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}}}}}]} 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} requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true} traceId: {type: string, nullable: true}
'403': {description: 无查看权限} '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: /api/platform/v1/first-leg/inbounds/{inboundId}/measurement:
get: get:
summary: 查询入库测量详情 summary: 查询入库测量详情
@@ -964,6 +997,43 @@ paths:
responses: responses:
'200': {description: 成功或业务失败,按响应体 code 判断} '200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 无测量编辑权限} '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: /api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans:
post: post:
summary: 平台模拟单箱设备扫描 summary: 平台模拟单箱设备扫描
@@ -1681,16 +1751,18 @@ components:
updatedAt: {type: string, format: date-time, nullable: true} updatedAt: {type: string, format: date-time, nullable: true}
CustomerDimensionConfigInitializeRequest: CustomerDimensionConfigInitializeRequest:
type: object type: object
required: [tenantId, operatorId, initializationKey] required: [tenantId, operatorId, initializationKey, customerName]
properties: properties:
tenantId: {type: string, pattern: '^[1-9][0-9]*$'} tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
operatorId: {type: string, pattern: '^[0-9]+$'} operatorId: {type: string, pattern: '^[0-9]+$'}
initializationKey: {type: string, minLength: 1, maxLength: 128} initializationKey: {type: string, minLength: 1, maxLength: 128}
customerName: {type: string, minLength: 1, maxLength: 128}
CustomerDimensionConfigInitializeResponse: CustomerDimensionConfigInitializeResponse:
type: object type: object
required: [configId, createdByThisInitialization] required: [configId, chargeRuleId, createdByThisInitialization]
properties: properties:
configId: {type: string, pattern: '^[1-9][0-9]*$'} configId: {type: string, pattern: '^[1-9][0-9]*$'}
chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$'}
createdByThisInitialization: {type: boolean} createdByThisInitialization: {type: boolean}
WeightPalletSnapshot: WeightPalletSnapshot:
type: object type: object
@@ -1933,6 +2005,25 @@ components:
minItems: 1 minItems: 1
maxItems: 500 maxItems: 500
items: {$ref: '#/components/schemas/InboundMeasurementItemRequest'} 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: InboundMeasurementItemRequest:
type: object type: object
required: [inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm] required: [inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm]
@@ -27,7 +27,7 @@ paths:
/api/platform/v1/containers/{id}/pda/photos/{fileId}/download: /api/platform/v1/containers/{id}/pda/photos/{fileId}/download:
get: get:
operationId: downloadContainerPdaPhoto 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}}] 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}} 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}: /api/platform/v1/containers/{id}/documents/{documentType}:
@@ -14,6 +14,7 @@ paths:
/api/platform/v1/terminal-drayages/{containerBookingId}: /api/platform/v1/terminal-drayages/{containerBookingId}:
get: get:
summary: 查询单柜码头拖车详情 summary: 查询单柜码头拖车详情
description: 已确认国内离仓的货柜即使船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情;实际操作仍按到港、清关和卸柜节点校验
x-permission: platform:firstleg:terminal-drayage:list x-permission: platform:firstleg:terminal-drayage:list
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}] parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
responses: {'200': {$ref: '#/components/responses/ViewResponse'}} responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
@@ -63,6 +64,7 @@ paths:
/api/platform/v1/terminal-drayages/{containerBookingId}/emails: /api/platform/v1/terminal-drayages/{containerBookingId}/emails:
get: get:
summary: 查询最近100封车队邮件及发信开关 summary: 查询最近100封车队邮件及发信开关
description: 已确认国内离仓的货柜允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件
x-permission: platform:firstleg:terminal-drayage:list x-permission: platform:firstleg:terminal-drayage:list
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}] parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
responses: responses:
@@ -1,20 +1,52 @@
package com.baiyue.firstleg.controller; 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.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.core.io.ClassPathResource; 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.RequestParam;
import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RequestPart;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Arrays; import java.util.Arrays;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat; 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("货柜基础资料接口契约") @DisplayName("货柜基础资料接口契约")
class ContainerControllerContractTest { 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 @Test
@DisplayName("查询、新增和编辑按钮权限彼此独立") @DisplayName("查询、新增和编辑按钮权限彼此独立")
void separatesButtonPermissions() { void separatesButtonPermissions() {
@@ -50,6 +82,7 @@ class ContainerControllerContractTest {
"/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:", "/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:",
"AWAITING_REVIEW", "version: {type: integer, minimum: 0}", "AWAITING_REVIEW", "version: {type: integer, minimum: 0}",
"downloadPath: {type: string, nullable: true", "downloadPath: {type: string, nullable: true",
"PDA完成及国内离仓后仍可读取ACTIVE历史照片",
"仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空") "仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空")
.doesNotContain("?containerNo", "containerNo in: query"); .doesNotContain("?containerNo", "containerNo in: query");
} }
@@ -33,15 +33,15 @@ class CustomerDimensionConfigControllerContractTest {
@Test @Test
void internalInitializationRequiresTokenAndReturnsCreationIdentity() { void internalInitializationRequiresTokenAndReturnsCreationIdentity() {
InitializeRequest request = new InitializeRequest(8L, 100L, "registration-1"); InitializeRequest request = new InitializeRequest(8L, 100L, "registration-1", "柏悦物流");
when(service.initializeDefault(8L, 100L, "registration-1")) when(service.initializeDefault(8L, 100L, "registration-1", "柏悦物流"))
.thenReturn(new InitializeResponse(91L, true)); .thenReturn(new InitializeResponse(91L, 191L, true));
assertThatThrownBy(() -> internalController.initializeDefault("wrong", request)) assertThatThrownBy(() -> internalController.initializeDefault("wrong", request))
.isInstanceOf(BizException.class); .isInstanceOf(BizException.class);
assertThat(internalController.initializeDefault("test-token", request).data()) assertThat(internalController.initializeDefault("test-token", request).data())
.isEqualTo(new InitializeResponse(91L, true)); .isEqualTo(new InitializeResponse(91L, 191L, true));
verify(service).initializeDefault(8L, 100L, "registration-1"); verify(service).initializeDefault(8L, 100L, "registration-1", "柏悦物流");
} }
@Test @Test
@@ -61,6 +61,22 @@ class InboundMeasurementEvidenceOpenApiContractTest {
assertThat(properties(schemas, "InboundMeasurementItemRequest")).containsKeys( assertThat(properties(schemas, "InboundMeasurementItemRequest")).containsKeys(
"inboundDetailId", "itemNo", "boxSpecification", "packageCount", "lengthCm", "inboundDetailId", "itemNo", "boxSpecification", "packageCount", "lengthCm",
"widthCm", "heightCm", "intelligentImageFileId", "remark"); "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"); Map<String, Object> itemProperties = properties(schemas, "InboundMeasurementItem");
assertThat(itemProperties).containsKeys( assertThat(itemProperties).containsKeys(
@@ -22,6 +22,8 @@ class InboundOrderControllerContractTest {
AuthenticatedUser.class, long.class, long.class); AuthenticatedUser.class, long.class, long.class);
assertAuthority("printCartonLabels", "hasAuthority('platform:firstleg:inbound:view')", assertAuthority("printCartonLabels", "hasAuthority('platform:firstleg:inbound:view')",
AuthenticatedUser.class, long.class, long.class, List.class, String.class); 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( Method page = PlatformInboundOrderController.class.getDeclaredMethod(
"page", AuthenticatedUser.class, Long.class, InboundOrderQuery.class); "page", AuthenticatedUser.class, Long.class, InboundOrderQuery.class);
@@ -47,6 +49,7 @@ class InboundOrderControllerContractTest {
.contains("/api/platform/inbounds:") .contains("/api/platform/inbounds:")
.contains("/api/platform/inbounds/{inboundId}:") .contains("/api/platform/inbounds/{inboundId}:")
.contains("/api/platform/inbounds/{inboundId}/carton-label-pdf:") .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("/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans:")
.contains("required: false") .contains("required: false")
.contains("InboundOrderSummary:") .contains("InboundOrderSummary:")
@@ -33,7 +33,7 @@ class ContainerCustomsControllerContractTest {
String yaml=new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8); String yaml=new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
new Yaml().load(yaml); new Yaml().load(yaml);
assertThat(yaml).contains("ContainerView:","DocumentView:","FileView:","InboundView:","ActionRequest:","ScopeRequest:") 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") .contains("LOADING_LIST_BUY_DECLARATION_ITEMS")
.doesNotContain("/cases/","CUSTOMS_DECLARATION"); .doesNotContain("/cases/","CUSTOMS_DECLARATION");
} }
@@ -23,6 +23,9 @@ class ContainerCustomsMapperContractTest {
@Test void actualCompletedLoadingItemsAndTaxModeAreTheOnlyTicketFacts() throws Exception { @Test void actualCompletedLoadingItemsAndTaxModeAreTheOnlyTicketFacts() throws Exception {
String plan=sql("planInbounds",long.class); String plan=sql("planInbounds",long.class);
assertThat(plan).contains("fl_loading_plan_item","fl_inbound_order","p.id=#{planId}","item.deleted=0"); 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); String missing=sql("missingRefundCount",long.class,long.class);
assertThat(missing).contains("i.tax_mode='TAX_REFUND'","fl_customs_customer_document_inbound","d.status='ACTIVE'"); assertThat(missing).contains("i.tax_mode='TAX_REFUND'","fl_customs_customer_document_inbound","d.status='ACTIVE'");
} }
@@ -44,6 +44,45 @@ class ContainerCustomsServiceTest {
assertMerchantOrderNotFound(7L,22L); 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(){ @Test void merchantCompletedTaxRefundOrderReturnsContainerView(){
var row=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND"); 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); when(mapper.completedPlanForOrder(7L,21L)).thenReturn(9L);
@@ -6,7 +6,6 @@ import com.baiyue.firstleg.loading.repository.LoadingRepository;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundProgress; import com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundProgress;
import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.service.FbaPricingService;
import com.baiyue.firstleg.service.InboundHoldGuard; import com.baiyue.firstleg.service.InboundHoldGuard;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -29,7 +28,6 @@ class LoadingDeliveryServiceTest {
@MockBean LoadingCompletionGateService completionGate; @MockBean LoadingCompletionGateService completionGate;
@MockBean InboundStatusHistoryMapper histories; @MockBean InboundStatusHistoryMapper histories;
@MockBean CabinetLoadingEvidenceService cabinetEvidence; @MockBean CabinetLoadingEvidenceService cabinetEvidence;
@MockBean FbaPricingService pricingService;
@MockBean ObjectMapper objectMapper; @MockBean ObjectMapper objectMapper;
@MockBean InboundHoldGuard inboundHoldGuard; @MockBean InboundHoldGuard inboundHoldGuard;
@Resource LoadingDeliveryService service; @Resource LoadingDeliveryService service;
@@ -50,7 +48,6 @@ class LoadingDeliveryServiceTest {
new com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest(3, "done")); new com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest(3, "done"));
verify(cabinetEvidence).requireComplete(99L); verify(cabinetEvidence).requireComplete(99L);
verify(pricingService).autoConfirmAfterLoading(10L, 20L);
} }
@Test @Test
@@ -91,7 +88,6 @@ class LoadingDeliveryServiceTest {
verify(repository).findInboundForUpdate(10L, 21L); verify(repository).findInboundForUpdate(10L, 21L);
verify(repository).completeLoadedChild(10L, 20L, 9L, 9); verify(repository).completeLoadedChild(10L, 20L, 9L, 9);
verify(pricingService).autoConfirmAfterLoading(10L, 21L);
} }
@Test @Test
@@ -146,6 +146,24 @@ class OutboundDispatchServiceTest {
verify(carrierSchedules, times(1)).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin"); 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 @Test
void confirmDoesNotWriteAnyRowWhenOneInboundGateFails() { void confirmDoesNotWriteAnyRowWhenOneInboundGateFails() {
OutboundDispatchService service = serviceWithoutCarrier(); OutboundDispatchService service = serviceWithoutCarrier();
@@ -38,8 +38,6 @@ class CarrierScheduleQueryOpenApiContractTest {
.contains("cabinetNo, orderNo, bookingNo and bill of lading are never fallbacks") .contains("cabinetNo, orderNo, bookingNo and bill of lading are never fallbacks")
.contains("latestActualNode:") .contains("latestActualNode:")
.contains("latestActualAt:") .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") .contains("internal execution fields are intentionally absent")
.doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED") .doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED")
.doesNotContain("enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]") .doesNotContain("enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]")
@@ -21,7 +21,7 @@ class CarrierScheduleQuerySqlContractTest {
.getAnnotation(Select.class).value()); .getAnnotation(Select.class).value());
assertThat(sql) assertThat(sql)
.contains("container_booking_id IN") .contains("WHERE b.id IN")
.contains("f.actual_at IS NOT NULL") .contains("f.actual_at IS NOT NULL")
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE'") .contains("MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE'")
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT'") .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='PICKUP_READY'")
.contains("MAX(CASE WHEN f.standard_node='PICKED_UP'") .contains("MAX(CASE WHEN f.standard_node='PICKED_UP'")
.contains("MAX(CASE WHEN f.standard_node='EMPTY_RETURNED'") .contains("MAX(CASE WHEN f.standard_node='EMPTY_RETURNED'")
.contains("ORDER BY q.container_booking_id") .contains("ORDER BY b.id")
.doesNotContain("estimated_at"); .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 @Test
@@ -93,6 +93,24 @@ class CarrierScheduleNodeOutboxServiceTest {
verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)); 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 @Test
void initialOutboundDepartureUsesVersionZeroWithoutEnvelopeFailure() throws Exception { void initialOutboundDepartureUsesVersionZeroWithoutEnvelopeFailure() throws Exception {
CarrierScheduleQueryEntity query = query(); CarrierScheduleQueryEntity query = query();
@@ -210,6 +210,24 @@ class CarrierScheduleQueryServiceTest {
&& "国内确认出仓自动创建".equals(history.getReason()))); && "国内确认出仓自动创建".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 @Test
void outboundUnknownCarrierCreatesManualTaskAndPreservesBookingCarrierIdentity() { void outboundUnknownCarrierCreatesManualTaskAndPreservesBookingCarrierIdentity() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
@@ -298,7 +316,7 @@ class CarrierScheduleQueryServiceTest {
@Test @Test
void outboundAndCarrierEnsureShareRequiredTransactionalRollbackBoundary() throws Exception { void outboundAndCarrierEnsureShareRequiredTransactionalRollbackBoundary() throws Exception {
Transactional carrierBoundary = CarrierScheduleQueryService.class.getMethod("ensureFromOutboundConfirm", 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", Transactional outboundBoundary = OutboundDispatchService.class.getMethod("confirm",
long.class, long.class, String.class, String.class, ActionRequest.class).getAnnotation(Transactional.class); 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()); 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 @Test
void manualEstimatedFactAdvancesVersionWithoutAdvancingActualProgress() { void manualEstimatedFactAdvancesVersionWithoutAdvancingActualProgress() {
CarrierScheduleQueryEntity before = task(0, 3); CarrierScheduleQueryEntity before = task(0, 3);
@@ -531,7 +521,7 @@ class CarrierScheduleQueryServiceTest {
existing.setEventKey("ZIM:evt-duplicate"); existing.setRawPayloadHash("a".repeat(64)); existing.setEventKey("ZIM:evt-duplicate"); existing.setRawPayloadHash("a".repeat(64));
when(repository.lock(1L)).thenReturn(task); when(repository.lock(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(false); 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", ProviderObservationRequest replay = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN",
"AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "a".repeat(64)); "AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "a".repeat(64));
@@ -210,20 +210,42 @@ class FbaPricingRepositoryRuleListTest {
.thenReturn(1); .thenReturn(1);
assertThat(repository.markInboundCalculated( 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); .isEqualTo(1);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
ArgumentCaptor<LambdaUpdateWrapper<InboundPricingEntity>> wrapper = ArgumentCaptor<LambdaUpdateWrapper<InboundPricingEntity>> wrapper =
ArgumentCaptor.forClass(LambdaUpdateWrapper.class); ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture()); 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"); .doesNotContain("processStatus");
assertThat(wrapper.getValue().getCustomSqlSegment()) assertThat(wrapper.getValue().getCustomSqlSegment())
.contains("tenantId", "id", "processStatus", "feeStatus"); .contains("tenantId", "id", "processStatus", "feeStatus");
assertThat(params(wrapper.getValue()).values()).contains(10001L, 88L, "WAIT_PALLETIZE"); 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) { private Collection<Object> queryValues(Long tenantId, String ruleCode) {
when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of());
@@ -89,16 +89,16 @@ class LoadingPickItemMapperContractTest {
} }
@Test @Test
void pdaWritesAndReadsRequireWarehouseScope() throws Exception { void pdaWritesAndReadsRelyOnUpstreamTaskScopeInsteadOfWarehouseScope() throws Exception {
Method list = LoadingPickItemMapper.class.getDeclaredMethod("selectTaskItemsWithAccount", long.class, long.class); Method list = LoadingPickItemMapper.class.getDeclaredMethod("selectTaskItemsWithAccount", long.class, long.class);
Method pick = LoadingPickItemMapper.class.getDeclaredMethod("pick", long.class, int.class, Method pick = LoadingPickItemMapper.class.getDeclaredMethod("pick", long.class, int.class,
int.class, long.class); int.class, long.class);
assertThat(list.getAnnotation(Select.class).value()[0]) assertThat(list.getAnnotation(Select.class).value()[0])
.contains("booking.warehouse_id = #{warehouseId}") .doesNotContain("booking.warehouse_id = #{warehouseId}")
.contains("plan_item.deleted = 0"); .contains("plan_item.deleted = 0");
assertThat(pick.getAnnotation(Update.class).value()[0]) assertThat(pick.getAnnotation(Update.class).value()[0])
.contains("booking.warehouse_id = #{warehouseId}") .doesNotContain("booking.warehouse_id = #{warehouseId}")
.contains("plan_item.deleted = 0"); .contains("plan_item.deleted = 0");
} }
} }
@@ -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");
}
}
@@ -22,6 +22,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
@@ -32,6 +33,8 @@ import static org.mockito.Mockito.when;
class CustomerDimensionConfigServiceTest { class CustomerDimensionConfigServiceTest {
@MockBean @MockBean
private CustomerDimensionConfigRepository repository; private CustomerDimensionConfigRepository repository;
@MockBean
private CustomerChargeRuleInitializationService chargeRuleInitialization;
@Resource @Resource
private CustomerDimensionConfigService service; private CustomerDimensionConfigService service;
@@ -40,10 +43,12 @@ class CustomerDimensionConfigServiceTest {
List<CustomerDimensionConfigEntity> persisted = defaultConfigs("registration-1"); List<CustomerDimensionConfigEntity> persisted = defaultConfigs("registration-1");
when(repository.findAll(8L)).thenReturn(List.of(), persisted); when(repository.findAll(8L)).thenReturn(List.of(), persisted);
when(repository.insertDefaults(any(), eq(100L))).thenReturn(4); 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") @SuppressWarnings("unchecked")
ArgumentCaptor<List<CustomerDimensionConfigEntity>> captor = ArgumentCaptor.forClass(List.class); ArgumentCaptor<List<CustomerDimensionConfigEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(repository).insertDefaults(captor.capture(), eq(100L)); verify(repository).insertDefaults(captor.capture(), eq(100L));
@@ -67,10 +72,12 @@ class CustomerDimensionConfigServiceTest {
@Test @Test
void retryAfterLostResponseKeepsCreationOwnershipForSameKey() { void retryAfterLostResponseKeepsCreationOwnershipForSameKey() {
when(repository.findAll(8L)).thenReturn(defaultConfigs("registration-1")); 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)); verify(repository, never()).insertDefaults(any(), eq(100L));
} }
@@ -78,10 +85,11 @@ class CustomerDimensionConfigServiceTest {
void differentInitializationKeyDoesNotTakeOwnershipOrOverwrite() { void differentInitializationKeyDoesNotTakeOwnershipOrOverwrite() {
when(repository.findAll(8L)).thenReturn(defaultConfigs("registration-1")); 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(repository, never()).insertDefaults(any(), eq(100L));
verify(chargeRuleInitialization, never()).initializeDefault(anyLong(), anyLong(), any(), any());
} }
@Test @Test
@@ -173,10 +181,14 @@ class CustomerDimensionConfigServiceTest {
when(repository.findDefaultsForCompensation(8L, "registration-1")) when(repository.findDefaultsForCompensation(8L, "registration-1"))
.thenReturn(defaultConfigs("registration-1")); .thenReturn(defaultConfigs("registration-1"));
when(repository.deleteUnchangedDefaults(8L, "registration-1")).thenReturn(4); 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"); service.compensateDefault(8L, "registration-1");
verify(repository).deleteUnchangedDefaults(8L, "registration-1"); verify(repository).deleteUnchangedDefaults(8L, "registration-1");
verify(chargeRuleInitialization).deleteUnchangedDefault(8L, "registration-1");
} }
@Test @Test
@@ -228,4 +240,11 @@ class CustomerDimensionConfigServiceTest {
} }
return values; 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); 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 @Test
@DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商") @DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商")
void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() { void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() {
@@ -258,7 +216,7 @@ class FbaPricingServiceTest {
assertThat(response.feeStatus()).isEqualTo("CALCULATED"); assertThat(response.feeStatus()).isEqualTo("CALCULATED");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_SPLIT", "KG", 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(feeDetails).materializeCalculatedFreight(TENANT, INBOUND, 501L, OPERATOR, NOW);
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any()); verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
} }
@@ -289,17 +247,27 @@ class FbaPricingServiceTest {
} }
@Test @Test
@DisplayName("装柜完成后禁止确认费用") @DisplayName("装柜完成但客户未确认时仍允许确认新费用版本")
void rejectsConfirmAfterLoadingCompleted() { void confirmsNewFeeVersionAfterLoadingCompleted() {
InboundPricingEntity inbound = inbound("10.000", "1.0000"); InboundPricingEntity inbound = inbound("10.000", "1.0000");
inbound.setProcessStatus("LOADED"); inbound.setProcessStatus("LOADED");
inbound.setFeeStatus("CALCULATED"); inbound.setFeeStatus("CALCULATED");
InboundFeeEntity fee = savedFee("CALCULATED");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); 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")) FeeSnapshotResponse response = service.confirm(TENANT, OPERATOR, INBOUND, "confirm-loaded");
.isInstanceOf(BizException.class)
.hasMessageContaining("装柜流程"); assertThat(response.nextProcessStatus()).isEqualTo("LOADED");
verify(repository, never()).markInboundConfirmed(TENANT, INBOUND); verify(repository).markInboundConfirmed(TENANT, INBOUND);
} }
@Test @Test
@@ -331,15 +299,15 @@ class FbaPricingServiceTest {
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_PALLETIZE"); assertThat(response.nextProcessStatus()).isEqualTo("WAIT_PALLETIZE");
verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_PALLETIZE", "CBM", 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 @Test
@DisplayName("客户未确认时待装柜订单可重新计算并替换已发送费用") @DisplayName("客户未确认时装柜完成订单仍可重新计算并替换已发送费用")
void recalculatesSentFeeBeforeCustomerConfirmation() { void recalculatesSentFeeBeforeCustomerConfirmation() {
readyRepository(); readyRepository();
InboundPricingEntity inbound = inbound("10", "1"); InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_PALLET"); inbound.setProcessStatus("LOADED");
inbound.setFeeStatus("SENT"); inbound.setFeeStatus("SENT");
InboundFeeEntity sent = savedFee("SENT"); InboundFeeEntity sent = savedFee("SENT");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
@@ -354,18 +322,18 @@ class FbaPricingServiceTest {
assertThat(response.feeVersion()).isEqualTo(2); assertThat(response.feeVersion()).isEqualTo(2);
assertThat(response.feeStatus()).isEqualTo("CALCULATED"); assertThat(response.feeStatus()).isEqualTo("CALCULATED");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_PALLET"); assertThat(response.nextProcessStatus()).isEqualTo("LOADED");
verify(repository).supersedeCurrent(TENANT, 501L, "SENT"); verify(repository).supersedeCurrent(TENANT, 501L, "SENT");
verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_PALLET", "CBM", verify(repository).markInboundCalculated(TENANT, INBOUND, "LOADED", "CBM",
new BigDecimal("1.0000")); new BigDecimal("10.000"), new BigDecimal("1.0000"), new BigDecimal("1.0000"));
} }
@Test @Test
@DisplayName("客户已确认时拒绝重新计算已发送费用") @DisplayName("客户已确认且装柜完成时拒绝重新计算已发送费用")
void rejectsRecalculationAfterCustomerConfirmation() { void rejectsRecalculationAfterCustomerConfirmation() {
readyRepository(); readyRepository();
InboundPricingEntity inbound = inbound("10", "1"); InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_PALLET"); inbound.setProcessStatus("LOADED");
inbound.setFeeStatus("SENT"); inbound.setFeeStatus("SENT");
InboundFeeEntity sent = savedFee("SENT"); InboundFeeEntity sent = savedFee("SENT");
sent.setCustomerConfirmedAt(NOW); sent.setCustomerConfirmedAt(NOW);
@@ -493,6 +461,7 @@ class FbaPricingServiceTest {
inbound.setProcessStatus("WAIT_SPLIT"); inbound.setProcessStatus("WAIT_SPLIT");
inbound.setFeeStatus("SENT"); inbound.setFeeStatus("SENT");
InboundFeeEntity current = savedFee("SENT"); InboundFeeEntity current = savedFee("SENT");
current.setCustomerConfirmedAt(NOW.minusHours(1));
AtomicReference<InboundFeeEntity> inserted = new AtomicReference<>(); AtomicReference<InboundFeeEntity> inserted = new AtomicReference<>();
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current); when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current);
@@ -501,7 +470,8 @@ class FbaPricingServiceTest {
.when(repository).insertIdempotency(any()); .when(repository).insertIdempotency(any());
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.supersedeCurrent(TENANT, 501L, "SENT")).thenReturn(1); 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); .thenReturn(1);
doAnswer(invocation -> { doAnswer(invocation -> {
InboundFeeEntity fee = invocation.getArgument(0); InboundFeeEntity fee = invocation.getArgument(0);
@@ -561,10 +531,10 @@ class FbaPricingServiceTest {
} }
@Test @Test
@DisplayName("客户已确认费用时拒绝平台再次修改") @DisplayName("客户已确认且装柜完成时拒绝平台再次修改")
void rejectsAdjustmentAfterCustomerConfirmation() { void rejectsAdjustmentAfterCustomerConfirmation() {
InboundPricingEntity inbound = inbound("10", "1"); InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_SPLIT"); inbound.setProcessStatus("LOADED");
InboundFeeEntity current = savedFee("SENT"); InboundFeeEntity current = savedFee("SENT");
current.setCustomerConfirmedAt(NOW); current.setCustomerConfirmedAt(NOW);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
@@ -596,7 +566,8 @@ class FbaPricingServiceTest {
.when(repository).insertIdempotency(any()); .when(repository).insertIdempotency(any());
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1); 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); .thenReturn(1);
doAnswer(invocation -> { doAnswer(invocation -> {
InboundFeeEntity fee = invocation.getArgument(0); InboundFeeEntity fee = invocation.getArgument(0);
@@ -655,7 +626,8 @@ class FbaPricingServiceTest {
.when(repository).insertIdempotency(any()); .when(repository).insertIdempotency(any());
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1); 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); .thenReturn(1);
doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(504L); return null; }) doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(504L); return null; })
.when(repository).insertFee(any()); .when(repository).insertFee(any());
@@ -803,7 +775,8 @@ class FbaPricingServiceTest {
when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2); when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2);
when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1); when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1);
when(repository.supersedeCurrent(eq(TENANT), anyLong(), anyString())).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); .thenReturn(1);
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn( when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(
regionEstimate("200", "8", "1600"), regionEstimate("200", "8", "1600")); regionEstimate("200", "8", "1600"), regionEstimate("200", "8", "1600"));
@@ -855,7 +828,8 @@ class FbaPricingServiceTest {
when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2); when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2);
when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1); when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1);
when(repository.supersedeCurrent(TENANT, 502L, "CALCULATED")).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); .thenReturn(1);
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("180", "8", "1440")); when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("180", "8", "1440"));
when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn( 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; }) doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(501L); return null; })
.when(repository).insertFee(any()); .when(repository).insertFee(any());
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(1); 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); when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
} }
@@ -21,6 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
class InboundCartonLabelPrintServiceTest { class InboundCartonLabelPrintServiceTest {
@@ -118,6 +119,39 @@ class InboundCartonLabelPrintServiceTest {
org.mockito.ArgumentMatchers.eq("request-batch"), org.mockito.ArgumentMatchers.any()); 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() { private InboundCartonLabels.Batch batch() {
InboundCartonLabels.Label label = new InboundCartonLabels.Label( InboundCartonLabels.Label label = new InboundCartonLabels.Label(
1L, 2L, 1, "音箱", 19, "PCHK2608040174#19-1", 19, 1, 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.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -233,6 +234,21 @@ class InboundCartonLabelServiceTest {
.hasMessageContaining("订单服务暂不可用"); .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 @Test
void repairsMissingHistoricalInboundFromOwnedOrderSnapshot() { void repairsMissingHistoricalInboundFromOwnedOrderSnapshot() {
var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand( var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand(
@@ -11,12 +11,14 @@ import com.baiyue.firstleg.dto.MeasurementBatchCommand;
import com.baiyue.firstleg.dto.InboundMeasurementResponses; import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity; import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.entity.InboundWeightRevisionEntity; import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository; import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository; import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
@@ -57,6 +59,7 @@ import static org.mockito.Mockito.when;
class InboundMeasurementServiceTest { class InboundMeasurementServiceTest {
@MockBean private InboundMeasurementRepository repository; @MockBean private InboundMeasurementRepository repository;
@MockBean private MeasurementBatchService batchService; @MockBean private MeasurementBatchService batchService;
@MockBean private FbaPricingRepository pricingRepository;
@MockBean private FbaMasterDataService fbaMasterDataService; @MockBean private FbaMasterDataService fbaMasterDataService;
@MockBean private OrderCargoClient orderCargoClient; @MockBean private OrderCargoClient orderCargoClient;
@MockBean private MeasurementImageClient imageClient; @MockBean private MeasurementImageClient imageClient;
@@ -90,6 +93,85 @@ class InboundMeasurementServiceTest {
verify(repository, never()).listWeights(anyLong(), anyLong()); 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 @Test
void correctingWeightKeepsOcrOriginalAndWritesImmutableRevision() { void correctingWeightKeepsOcrOriginalAndWritesImmutableRevision() {
InboundOrderEntity inbound = inbound("MEASURING"); InboundOrderEntity inbound = inbound("MEASURING");
@@ -1089,7 +1171,7 @@ class InboundMeasurementServiceTest {
} }
@Test @Test
void submitDoesNotBlockOnUnconfirmedWeight() { void submitRejectsWhenNoWeightImageHasBeenConfirmed() {
InboundOrderEntity inbound = inbound("MEASURING"); InboundOrderEntity inbound = inbound("MEASURING");
InboundMeasurementEntity measurement = new InboundMeasurementEntity(); InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L); measurement.setId(30L);
@@ -1103,16 +1185,37 @@ class InboundMeasurementServiceTest {
when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); when(repository.listItems(1L, 30L)).thenReturn(List.of(item));
when(repository.listWeights(1L, 10L)).thenReturn(List.of( when(repository.listWeights(1L, 10L)).thenReturn(List.of(
weight(20L, "20", "KG", "PENDING_CONFIRM", 0))); weight(20L, "20", "KG", "PENDING_CONFIRM", 0)));
when(repository.updateMeasurement(measurement)).thenReturn(true); assertThatThrownBy(() -> service.submit(1L, 10L, 9L,
when(repository.updateInbound(inbound)).thenReturn(true); new InboundMeasurementRequests.SubmitRequest(30L, 0)))
.isInstanceOf(BizException.class)
.hasMessageContaining("至少上传并确认一张称重图片");
InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L, verify(repository, never()).updateMeasurement(any());
new InboundMeasurementRequests.SubmitRequest(30L, 0)); verify(repository, never()).updateInbound(any());
}
assertThat(result.processStatus()).isEqualTo("MEASURE_PENDING"); @Test
assertThat(inbound.getActualGrossWeightKg()).isNull(); void submitRejectsWhenThereIsNoWeightImage() {
verify(repository).updateMeasurement(measurement); InboundOrderEntity inbound = inbound("MEASURING");
verify(repository).updateInbound(inbound); 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 @Test
@@ -521,16 +521,22 @@ class LoadingPickServiceTest {
} }
@Test @Test
void taskDetailRejectsWarehouseThatDoesNotOwnTheContainer() { void taskDetailDoesNotRecheckWarehouseOwnership() {
LoadingPickTaskEntity task = task(); LoadingPickTaskEntity task = task();
when(repository.task(20L)).thenReturn(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)) var result = service.task(20L, 999L);
.isInstanceOf(BizException.class).hasMessageContaining("不属于当前仓库");
verify(repository, never()).items(20L, 999L); assertThat(result.id()).isEqualTo(20L);
verify(repository, never()).cartonLabels(20L); assertThat(result.items()).hasSize(1);
verify(completionCommands, never()).latest(20L); assertThat(result.items().get(0).inventoryResources()).singleElement()
.extracting("availablePackageCount").isEqualTo(2);
} }
@Test @Test
@@ -1412,6 +1418,40 @@ class LoadingPickServiceTest {
verify(settlementService, never()).settle(20L, 9L, "operator"); 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 @Test
void rejectsBatchTaskVersionConflictBeforeWriting() { void rejectsBatchTaskVersionConflictBeforeWriting() {
LoadingPickTaskEntity task = task(); LoadingPickTaskEntity task = task();
@@ -1561,6 +1601,11 @@ class LoadingPickServiceTest {
} }
private static InboundPalletAllocationEntity allocation(long inboundId, int packageCount) { 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(); InboundPalletAllocationEntity allocation = new InboundPalletAllocationEntity();
allocation.setId(72L); allocation.setId(72L);
allocation.setTenantId(1L); allocation.setTenantId(1L);
@@ -1568,7 +1613,7 @@ class LoadingPickServiceTest {
allocation.setBlockId(71L); allocation.setBlockId(71L);
allocation.setSlotNo(1); allocation.setSlotNo(1);
allocation.setAllocationNo(1); allocation.setAllocationNo(1);
allocation.setWarehouseId(101L); allocation.setWarehouseId(warehouseId);
allocation.setPalletId(73L); allocation.setPalletId(73L);
allocation.setLocationId(74L); allocation.setLocationId(74L);
allocation.setLocationCode("A-01"); 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -97,6 +98,81 @@ class MeasurementBatchServiceTest {
assertThat(items.getValue().get(0).getInboundDetailId()).isEqualTo(80001L); 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 @Test
void automaticallyAppliesCustomerDefaultRuleToActualMeasurement() { void automaticallyAppliesCustomerDefaultRuleToActualMeasurement() {
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity(); CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
@@ -1,6 +1,7 @@
package com.baiyue.firstleg.terminaldrayage.controller; package com.baiyue.firstleg.terminaldrayage.controller;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.apache.ibatis.annotations.Select;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import java.nio.file.Files; import java.nio.file.Files;
@@ -50,10 +51,24 @@ class TerminalDrayageOpenApiContractTest {
"platform:firstleg:terminal-drayage:pickup:confirm", "platform:firstleg:terminal-drayage:pickup:confirm",
"platform:firstleg:terminal-drayage:empty-return:confirm", "platform:firstleg:terminal-drayage:empty-return:confirm",
"expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt", "expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt",
"emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays"); "emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays",
"船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情",
"允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件");
assertThat(yaml).contains("description: 卸柜完成时间加72小时"); 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) { private static void assertPermission(String methodName, String permission) {
var method = java.util.Arrays.stream(PlatformTerminalDrayageController.class.getDeclaredMethods()) var method = java.util.Arrays.stream(PlatformTerminalDrayageController.class.getDeclaredMethods())
.filter(value -> methodName.equals(value.getName())).findFirst().orElseThrow(); .filter(value -> methodName.equals(value.getName())).findFirst().orElseThrow();
@@ -61,7 +61,7 @@ class TerminalDrayageSqlContractTest {
"UNIQUE KEY uk_terminal_drayage_operation(container_booking_id,idempotency_key)", "UNIQUE KEY uk_terminal_drayage_operation(container_booking_id,idempotency_key)",
"服务端按卸柜完成时间加72小时计算的预计还柜时间"); "服务端按卸柜完成时间加72小时计算的预计还柜时间");
assertThat(sql).doesNotContain("ON DELETE CASCADE"); 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}") "WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}")
.doesNotContain("fl_carrier_schedule_query", "fl_carrier_schedule_node_fact"); .doesNotContain("fl_carrier_schedule_query", "fl_carrier_schedule_node_fact");
} }
@@ -84,6 +84,35 @@ class ContainerWorkbenchServiceTest {
verifyNoInteractions(carrierSchedules); 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 @Test
void realOverseasListAndDetailProjectBothEmptyReturnActions() throws Exception { void realOverseasListAndDetailProjectBothEmptyReturnActions() throws Exception {
verifyRealEmptyReturnProjection(true, true); verifyRealEmptyReturnProjection(true, true);
@@ -139,8 +168,8 @@ class ContainerWorkbenchServiceTest {
var serialized = json.readTree(json.writeValueAsString(com.baiyue.common.core.api.ApiResponse.success(page))); 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/allowedActions")).isEqualTo(json.valueToTree(expected));
assertThat(serialized.at("/data/records/0/terminalDrayage/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, times(2)).findAll(List.of(11L));
verify(mapper).contexts(List.of(11L)); verify(mapper, times(2)).contexts(List.of(11L));
} }
@Test @Test
@@ -2,7 +2,7 @@ package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseSupplierClient; 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.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
@@ -125,8 +125,8 @@ class TerminalDrayageServiceTest {
assertThat(result.status()).isEqualTo("PICKED_UP"); assertThat(result.status()).isEqualTo("PICKED_UP");
assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0)); assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0));
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class); ArgumentCaptor<ContainerManualNodeRequest> node = ArgumentCaptor.forClass(ContainerManualNodeRequest.class);
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture()); verify(carrierSchedules).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
assertThat(node.getValue().standardNode()).isEqualTo("PICKED_UP"); assertThat(node.getValue().standardNode()).isEqualTo("PICKED_UP");
verify(mapper, times(1)).updateAggregate(any(), anyInt(), anyLong(), anyString()); 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))) new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 10, 0), null)))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.hasMessage("海外卸柜收货完成后才能确认还柜"); .hasMessage("海外卸柜收货完成后才能确认还柜");
verify(carrierSchedules, never()).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any()); verify(carrierSchedules, never()).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
} }
@Test @Test
@@ -212,7 +212,7 @@ class TerminalDrayageServiceTest {
assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN"); assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN");
assertThat(service.recordEmptyReturn(11L, 7L, "admin", "return-new", request)).isEqualTo(result); assertThat(service.recordEmptyReturn(11L, 7L, "admin", "return-new", request)).isEqualTo(result);
verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class)); 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 @Test
@@ -222,7 +222,7 @@ class TerminalDrayageServiceTest {
new ActualTimeRequest(0, LocalDateTime.of(2026, 9, 2, 23, 0), null))) new ActualTimeRequest(0, LocalDateTime.of(2026, 9, 2, 23, 0), null)))
.isInstanceOf(BizException.class).hasMessage("实际还柜时间不能早于提柜或卸柜完成时间"); .isInstanceOf(BizException.class).hasMessage("实际还柜时间不能早于提柜或卸柜完成时间");
verify(mapper, never()).insert(any(TerminalDrayageEntity.class)); 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 @Test
@@ -243,13 +243,26 @@ class TerminalDrayageServiceTest {
} }
@Test @Test
void bulkViewOmitsContainerWhenActualFactPortHasNoTrackingRecord() { void readsDepartedContainerBeforeCarrierTrackingRecordExists() {
when(mapper.contexts(List.of(11L))).thenReturn(List.of( when(mapper.contexts(List.of(11L))).thenReturn(List.of(
new Context(11L, "MSCU1234567", 21L, "ACTIVE", null))); new Context(11L, "MSCU1234567", 21L, "ACTIVE", null)));
when(mapper.findAll(List.of(11L))).thenReturn(List.of()); 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(); var bulk = service.views(List.of(11L));
verify(carrierSchedules).containerActualFacts(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 @Test
@@ -338,8 +351,8 @@ class TerminalDrayageServiceTest {
assertThat(result.status()).isEqualTo("EMPTY_RETURNED"); assertThat(result.status()).isEqualTo("EMPTY_RETURNED");
assertThat(result.estimatedEmptyReturnAt()).isEqualTo(unloadedAt.plusHours(72)); assertThat(result.estimatedEmptyReturnAt()).isEqualTo(unloadedAt.plusHours(72));
assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED"); assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED");
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class); ArgumentCaptor<ContainerManualNodeRequest> node = ArgumentCaptor.forClass(ContainerManualNodeRequest.class);
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture()); verify(carrierSchedules).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED"); assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED");
assertThat(node.getValue().reason()).isEqualTo("已还柜"); assertThat(node.getValue().reason()).isEqualTo("已还柜");
assertThat(node.getValue().actualAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 9, 0)); assertThat(node.getValue().actualAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 9, 0));
+1 -1
View File
@@ -77,7 +77,7 @@ spring:
- id: firstleg-service - id: firstleg-service
uri: lb://firstleg-service uri: lb://firstleg-service
predicates: 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 - id: finance-service
uri: lb://finance-service uri: lb://finance-service
predicates: predicates:
@@ -39,6 +39,7 @@ class FirstlegContainerRouteContractTest {
.contains("/api/merchant/v1/customs/**") .contains("/api/merchant/v1/customs/**")
.contains("/api/platform/v1/port-schedules/**") .contains("/api/platform/v1/port-schedules/**")
.contains("/api/platform/v1/carrier-schedule-queries/**") .contains("/api/platform/v1/carrier-schedule-queries/**")
.contains("/api/platform/v1/container-shipping-nodes/**")
.contains("/api/platform/v1/first-leg/**"); .contains("/api/platform/v1/first-leg/**");
} }
} }
+1 -1
View File
@@ -13,7 +13,7 @@
| 目标 | 入口 | | 目标 | 入口 |
|---|---| |---|---|
| 客户资料/审核与企业联系信息 | `controller/PlatformTenantController.java`、`service/TenantService.java`;公司联系电话/邮箱属于企业资料,不作为登录标识且不要求跨企业唯一;审核必填资质仅营业执照与统一社会信用代码,法人身份证选填 | | 客户资料/审核与企业联系信息 | `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、撤销该账号全部会话并审计,不与公司联系电话联动 | | 商户成员、登录手机号与个人资料 | `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` | | 平台用户与海外 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` | | 角色/权限/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.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam; 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", @FeignClient(name = "firstleg-service", contextId = "customerDimensionRuleClient",
url = "${baiyue.clients.firstleg.url:}", url = "${baiyue.clients.firstleg.url:}",
path = "/internal/firstleg/v1/customer-dimension-configs") path = "/internal/firstleg/v1/customer-dimension-configs")
@@ -24,7 +24,8 @@ public interface CustomerDimensionRuleClient {
@RequestParam long tenantId, @RequestParam long tenantId,
@RequestParam String initializationKey); @RequestParam String initializationKey);
record InitializationRequest(long tenantId, long operatorId, String initializationKey) { } record InitializationRequest(long tenantId, long operatorId, String initializationKey,
/** configId is the first record ID retained only for contract compatibility. */ String customerName) { }
record InitializationResponse(Long configId, boolean createdByThisInitialization) { } record InitializationResponse(Long configId, Long chargeRuleId,
boolean createdByThisInitialization) { }
} }
@@ -3,10 +3,10 @@ package com.baiyue.iam.model;
public record CustomerDimensionInitializationTask( public record CustomerDimensionInitializationTask(
long tenantId, long tenantId,
long operatorId, long operatorId,
String customerName,
String initializationKey, String initializationKey,
String action, String action,
String status, String status,
int attemptCount, int attemptCount,
int taskVersion) { int taskVersion) {
} }
@@ -73,11 +73,14 @@ public class CustomerDimensionInitializationRepository {
+ "WHERE tenant_id=? AND status='PENDING' AND next_retry_at<=?", + "WHERE tenant_id=? AND status='PENDING' AND next_retry_at<=?",
workerId, now.plusMinutes(2), now, tenantId, now); workerId, now.plusMinutes(2), now, tenantId, now);
if (claimed != 1) return null; if (claimed != 1) return null;
return jdbc.queryForObject("SELECT tenant_id,operator_id,initialization_key,action,status," return jdbc.queryForObject("SELECT task.tenant_id,task.operator_id,tenant.tenant_name,"
+ "attempt_count,task_version FROM iam_customer_dimension_initialization_task " + "task.initialization_key,task.action,task.status,task.attempt_count,task.task_version "
+ "WHERE tenant_id=? AND status='SENDING' AND lease_owner=?", + "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, 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.getString("action"), rs.getString("status"), rs.getInt("attempt_count"),
rs.getInt("task_version")), tenantId, workerId); rs.getInt("task_version")), tenantId, workerId);
} }
@@ -43,10 +43,11 @@ public class CustomerDimensionInitializationRelay {
} }
ApiResponse<CustomerDimensionRuleClient.InitializationResponse> response = client.initialize( ApiResponse<CustomerDimensionRuleClient.InitializationResponse> response = client.initialize(
internalToken, new CustomerDimensionRuleClient.InitializationRequest( internalToken, new CustomerDimensionRuleClient.InitializationRequest(
task.tenantId(), task.operatorId(), task.initializationKey())); task.tenantId(), task.operatorId(), task.initializationKey(), task.customerName()));
requireSuccess(response, true); requireSuccess(response, true);
if (response.data() == null || response.data().configId() == null) { if (response.data() == null || response.data().configId() == null
throw new DeliveryFailure("S0503", "Firstleg 未返回默认尺寸配置ID", false); || response.data().chargeRuleId() == null) {
throw new DeliveryFailure("S0503", "Firstleg 未完整返回默认尺寸和权重系数规则ID", false);
} }
repository.markReady(task, workerId, response.data().configId()); repository.markReady(task, workerId, response.data().configId());
} catch (Exception failure) { } 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), (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), (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), (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 ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), status = 'ACTIVE', deleted = 0; 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) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0 FROM iam_permission 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); ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000003, id, 0 FROM iam_permission 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); ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES
@@ -9,7 +9,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class CustomerDimensionRuleClientContractTest { class CustomerDimensionRuleClientContractTest {
@Test @Test
void usesOneAtomicCallForTheFourDefaultBoxDimensionRecords() throws Exception { void usesOneAtomicCallForBoxDimensionsAndWeightCoefficientRule() throws Exception {
FeignClient feign = CustomerDimensionRuleClient.class.getAnnotation(FeignClient.class); FeignClient feign = CustomerDimensionRuleClient.class.getAnnotation(FeignClient.class);
PostMapping initialize = CustomerDimensionRuleClient.class PostMapping initialize = CustomerDimensionRuleClient.class
.getMethod("initialize", String.class, .getMethod("initialize", String.class,
@@ -25,6 +25,6 @@ class CustomerDimensionRuleClientContractTest {
assertThat(compensate.value()).containsExactly("/defaults"); assertThat(compensate.value()).containsExactly("/defaults");
assertThat(CustomerDimensionRuleClient.InitializationResponse.class.getRecordComponents()) assertThat(CustomerDimensionRuleClient.InitializationResponse.class.getRecordComponents())
.extracting(java.lang.reflect.RecordComponent::getName) .extracting(java.lang.reflect.RecordComponent::getName)
.containsExactly("configId", "createdByThisInitialization"); .containsExactly("configId", "chargeRuleId", "createdByThisInitialization");
} }
} }
@@ -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);
}
}
@@ -43,15 +43,33 @@ class CustomerDimensionInitializationRelayTest {
CustomerDimensionInitializationTask task = task("INITIALIZE", 0); CustomerDimensionInitializationTask task = task("INITIALIZE", 0);
when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task); when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task);
when(client.initialize(anyString(), any())).thenReturn(ApiResponse.success( when(client.initialize(anyString(), any())).thenReturn(ApiResponse.success(
new CustomerDimensionRuleClient.InitializationResponse(91L, true))); new CustomerDimensionRuleClient.InitializationResponse(91L, 191L, true)));
relay.deliver(10001L); 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).markReady(task, workerId(), 91L);
verify(repository, never()).recordFailure(any(), anyString(), anyInt(), any(), anyString(), verify(repository, never()).recordFailure(any(), anyString(), anyInt(), any(), anyString(),
anyString(), any(Boolean.class)); 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 @Test
void transientFailureStaysPendingWithDiagnosticAndBackoff() { void transientFailureStaysPendingWithDiagnosticAndBackoff() {
CustomerDimensionInitializationTask task = task("INITIALIZE", 0); CustomerDimensionInitializationTask task = task("INITIALIZE", 0);
@@ -95,7 +113,7 @@ class CustomerDimensionInitializationRelayTest {
} }
private CustomerDimensionInitializationTask task(String action, int attempts) { 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); 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) 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')) <> (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND permission_type IN ('MENU', 'BUTTON'))
UNION ALL 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' SELECT 'missing_global_merchant_owner_template'
WHERE NOT EXISTS ( WHERE NOT EXISTS (
SELECT 1 FROM baiyue_iam.iam_role SELECT 1 FROM baiyue_iam.iam_role

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