Merge remote-tracking branch 'origin/dev' into dev

# Conflicts:
#	deploy/sql/海外区域统一增量-MySQL8.sql
#	lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java
#	lastleg-service/src/main/resources/db/lastleg.sql
#	lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
#	lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java
#	scripts/README.md
This commit is contained in:
2026-09-10 17:25:07 +08:00
233 changed files with 7862 additions and 2018 deletions
+2
View File
@@ -67,6 +67,8 @@
| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 |
| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` |
散货托盘码与跨区传递:`firstleg/` 的 `LoadingPalletLabelService.java` 生成冻结标签并调用 Print 输出 PDF,`LoadingCompletedOutboxService.java` 传递装柜快照;`lastleg-service/` 通过 `LastlegSourcePalletMapper.java` 保存来源托盘,`InboundPlanService.java` 和 `ContainerInboundPlanConversionCoordinator.java` 消费。标签与资源预留的细节继续读对应服务指引。
## 公共模块快速索引
| 关键词 | 模块与入口 |
+2 -2
View File
@@ -16,9 +16,9 @@
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` |
| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` |
| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 |
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次,信封固定使用 `tenantId=0` 平台技术范围;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
| 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 |
| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;按 `containerBookingId` 柜级聚合,不携带客户,信封固定使用 `tenantId=0` 平台技术范围;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Event Bridge 定向投递国内 `finance/`,Finance 不跨区同步回查 |
| 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 |
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
@@ -44,6 +44,15 @@ public record BusinessFeeSourceChangedV1(
LocalDate businessDate,
String currency,
BigDecimal totalAmount,
String originalCurrency,
BigDecimal originalAmount,
BigDecimal exchangeRate,
String exchangeRateMonth,
BigDecimal chargeableQuantity,
String quantityUnit,
BigDecimal unitPrice,
LocalDate periodStart,
LocalDate periodEnd,
String reviewStatus,
String publicationStatus,
String sourceStatus,
@@ -7,7 +7,6 @@ import java.util.Map;
/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */
public record FirstLegContainerShippingNodeRecordedV1(
String queryId,
String customerId,
String oceanBookingId,
@JsonProperty("sourceContainerBookingId") String containerBookingId,
String containerNo,
@@ -29,8 +28,7 @@ public record FirstLegContainerShippingNodeRecordedV1(
"EMPTY_RETURNED", 100);
public FirstLegContainerShippingNodeRecordedV1 {
if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank()
|| oceanBookingId == null || oceanBookingId.isBlank()
if (queryId == null || queryId.isBlank() || oceanBookingId == null || oceanBookingId.isBlank()
|| containerBookingId == null || containerBookingId.isBlank()
|| containerNo == null || containerNo.isBlank()) {
throw new IllegalArgumentException("船节点交接业务标识不能为空");
@@ -29,7 +29,7 @@ class FirstLegContainerShippingNodeRecordedV1Test {
@Test
void rejectsNonCanonicalPhysicalContainerNumber() {
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
"1", "2", "3", "4", " zcsu7881970 ", "PICKUP_READY", 80,
"1", "3", "4", " zcsu7881970 ", "PICKUP_READY", 80,
Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
null, null, "ZIM", "evt", "AVAILABLE", "ZIM:evt", "a".repeat(64)))
.hasMessageContaining("normalized");
@@ -38,13 +38,13 @@ class FirstLegContainerShippingNodeRecordedV1Test {
@Test
void rejectsEstimatedOnlyOrIncompleteProviderFacts() {
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
"1", "2", "3", "4", "ZCSU7881970", "PICKUP_READY", 80,
"1", "3", "4", "ZCSU7881970", "PICKUP_READY", 80,
Instant.parse("2026-08-25T01:00:00Z"), null, null, null,
"ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64)))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间");
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
"1", "2", "3", "4", "ZCSU7881970", "ARRIVED", 60,
"1", "3", "4", "ZCSU7881970", "ARRIVED", 60,
Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
null, null, "ZIM", "evt-2", "ARRIVED", "ZIM:evt-2", "b".repeat(64)))
.isInstanceOf(IllegalArgumentException.class);
@@ -52,7 +52,7 @@ class FirstLegContainerShippingNodeRecordedV1Test {
private static FirstLegContainerShippingNodeRecordedV1 payload() {
return new FirstLegContainerShippingNodeRecordedV1(
"2088000000000000001", "2088000000000000002", "2088000000000000004",
"2088000000000000001", "2088000000000000004",
"2088000000000000003", "ZCSU7881970", "PICKUP_READY", 80,
Instant.parse("2026-08-25T01:02:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
"USLAX", "Los Angeles", "ZIM", "evt-1", "AVAILABLE",
+1 -1
View File
@@ -1,7 +1,7 @@
name: firstleg-${DEPLOY_ENV:-test}-overseas
x-overseas-common: &overseas-common
restart: unless-stopped
restart: "on-failure:3"
networks: [firstleg-overseas-network, infra-network]
extra_hosts:
- "host.docker.internal:host-gateway"
+3 -3
View File
@@ -9,14 +9,14 @@ x-bounded-logging: &bounded-logging
services:
rocketmq-namesrv:
image: apache/rocketmq:5.3.1
restart: unless-stopped
restart: "on-failure:3"
command: sh mqnamesrv
logging: *bounded-logging
networks: [infra-network]
rocketmq-broker:
image: apache/rocketmq:5.3.1
restart: unless-stopped
restart: "on-failure:3"
depends_on: [rocketmq-namesrv]
environment:
ROCKETMQ_BROKER_IP: ${ROCKETMQ_BROKER_IP:?ROCKETMQ_BROKER_IP is required}
@@ -53,7 +53,7 @@ services:
sh mqadmin updateTopic -n rocketmq-namesrv:9876 -c BaiyueCluster
-t "%DLQ%$${MQ_TOPIC_PREFIX}baiyue-audit-consumer-v1" -r 1 -w 1 -p 6'
# Broker 注册较慢或任一初始化命令失败时整组重试;所有命令均幂等。
restart: on-failure
restart: "on-failure:3"
logging: *bounded-logging
networks: [infra-network]
+8 -1
View File
@@ -1,7 +1,7 @@
name: firstleg-${DEPLOY_ENV:-test}
x-common: &common
restart: unless-stopped
restart: "on-failure:3"
networks: [firstleg-network, infra-network]
# 测试机复用宿主机的 MySQL、Redis、Nacos 时,容器通过此别名访问宿主机。
extra_hosts:
@@ -104,6 +104,13 @@ services:
CARRIER_SCHEDULE_BATCH_SIZE: ${CARRIER_SCHEDULE_BATCH_SIZE:-20}
CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:-2000}
CARRIER_SCHEDULE_LEASE_DURATION: ${CARRIER_SCHEDULE_LEASE_DURATION:-PT5M}
TERMINAL_DRAYAGE_MAIL_ENABLED: ${TERMINAL_DRAYAGE_MAIL_ENABLED:-false}
TERMINAL_DRAYAGE_MAIL_HOST: ${TERMINAL_DRAYAGE_MAIL_HOST:-}
TERMINAL_DRAYAGE_MAIL_PORT: ${TERMINAL_DRAYAGE_MAIL_PORT:-587}
TERMINAL_DRAYAGE_MAIL_USERNAME: ${TERMINAL_DRAYAGE_MAIL_USERNAME:-}
TERMINAL_DRAYAGE_MAIL_PASSWORD: ${TERMINAL_DRAYAGE_MAIL_PASSWORD:-}
TERMINAL_DRAYAGE_MAIL_SSL: ${TERMINAL_DRAYAGE_MAIL_SSL:-false}
TERMINAL_DRAYAGE_MAIL_SCAN_DELAY: ${TERMINAL_DRAYAGE_MAIL_SCAN_DELAY:-10000}
finance:
<<: *common
+8
View File
@@ -130,6 +130,14 @@ if [ "$SERVICE" = "firstleg" ]; then
echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2
exit 2
fi
if [ "$(env_file_value TERMINAL_DRAYAGE_MAIL_ENABLED)" = "true" ]; then
for key in TERMINAL_DRAYAGE_MAIL_HOST TERMINAL_DRAYAGE_MAIL_PORT TERMINAL_DRAYAGE_MAIL_USERNAME TERMINAL_DRAYAGE_MAIL_PASSWORD; do
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "启用码头车队邮件时,环境文件必须包含非空 $key" >&2
exit 2
fi
done
fi
fi
if [ "$SERVICE" = "lastleg-service" ]; then
+7 -3
View File
@@ -3,8 +3,8 @@
## 唯一数据库初始化与发布入口
- 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。
- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`与 `warehouse-overseas-service/src/main/resources/db/warehouse.sql` 基线,再执行该入口。固定入口会创建并校验独立的 `baiyue_event_bridge_overseas`,再升级 `baiyue_lastleg`与 `baiyue_lastlag_warehouse`;IAM 安全事件 Inbox 和可恢复投影已并入 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。
- Lastleg 基线和海外固定增量仅确定性初始化国内固定 `platform_admin`(`authUserId=1000001`、`tenantId=0`)的 USER/AUTHORIZATION version=0 安全投影;其他既有开发账号不建立兼容表或长期补投入口。
- 海外区域固定入口:`海外区域统一增量-MySQL8.sql`。该文件已经内联 Lastleg 与 Warehouse Overseas 的完整空库基线,会自行创建并初始化 `baiyue_lastleg`、`baiyue_lastlag_warehouse` 和独立的 `baiyue_event_bridge_overseas`,清库后不得再额外执行模块基线。IAM 安全事件 Inbox 和可恢复投影位于 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。
- Lastleg 基线和海外固定增量仅确定性初始化国内固定 `platform_admin`(`authUserId=1000001`、`tenantId=0`)的 USER/AUTHORIZATION version=0 安全投影;AUTHORIZATION 同步冻结当前 `PLATFORM_ADMIN` 角色及 IAM 跨区前缀下的完整权限集合,保证空库重建后海外 Gateway 可立即鉴权。统一 SQL 重放只修正仍处于固定 baseline version=0 的该行,其他既有开发账号不建立兼容表或长期补投入口。
- 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。
- 该脚本汇总订单与客户、入库测量与计费及已实现国内货柜履约出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。
- 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。
@@ -239,8 +239,12 @@ SELECT
- 派送三类资料当前基线包含 `ll_delivery_order.bol_no/documents_json` 与 `ll_delivery_document_revision`;海外固定入口和 Lastleg 模块基线同步维护这些定义。仅保存装车照片、BOL文件(含签名)、签收文件(POD签收/快递签收)三个类别,摘要按类别计数。资料历史不可变,文件对象由 File 按真实客户与派送单绑定;不按 BOL 文本复用文件,不生成旧类别迁移或清理脚本。
- 海外仓出库与派送数据库部署仍只执行 `海外区域统一增量-MySQL8.sql`;空库先执行 Lastleg 与 Warehouse Overseas 当前基线,再完整执行固定入口并确认最末 `migration_status = PASS`。
- 海外仓出库与派送数据库部署只执行 `海外区域统一增量-MySQL8.sql`;无论空库还是存量测试库都不得在此前单独执行 Lastleg 或 Warehouse Overseas 模块基线,完整执行固定入口并确认最末 `migration_status = PASS`。
- 该海外固定入口会在任何海外仓出库与派送 `ALTER` 与数据门禁前幂等创建排车 PDA、出库、派送、一件代发、异常订单和客户回访的完整当前结构,因此既有海外仓业务库不依赖模块基线或历史 `scripts/db/incremental.sql`。国内固定入口只保留 IAM 海外仓出库与派送权限目录,不创建或修改 `baiyue_lastleg` 业务表。
- 固定入口只补目标字段并做 fail-fast 校验,不回填、改号、清理或从其他表推断开发旧行。`ll_dispatch_pda_task.warehouse_id` 存在 `NULL`、空值或 `0`,或任一待收敛全局业务号/任务号、完成幂等键发生冲突时,脚本立即 `SIGNAL`;应重新造数,重置或清理仍需用户另行授权。
- 数据门禁通过后,脚本原位把 `dispatch_no`、`outbound_no`、排车 PDA `task_no`、出库 PDA `task_no` 收敛为全局唯一,并建立 `(customer_id, completion_idempotency_key)` 完成命令唯一约束;旧客户复合索引不保留。
- 必须在启动或重启 Lastleg Service 前完成固定入口;不得单独执行模块基线补丁、日期脚本或手工兼容 SQL。
## 码头车队邮件
国内统一入口包含 `fl_terminal_drayage_mail`,用于冻结收件地址、两类邮件正文、投递状态和失败信息;创建后才可发布相关 Firstleg 功能。发信配置默认关闭,配置说明见 [Firstleg 车队邮件](../../firstleg/docs/terminal-drayage-mail.md)。邮件功能涉及 Admin、Firstleg 和 Warehouse 内部联系资料接口,无新增权限码。
+478 -271
View File
@@ -843,6 +843,7 @@ CREATE TABLE IF NOT EXISTS wh_warehouse (
id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓库固定使用 tenant_id=0',
warehouse_code VARCHAR(50) NOT NULL COMMENT '平台内唯一仓库编码',
internal_code VARCHAR(16) NULL COMMENT 'FBA内部编号,仅FBA仓可配置',
warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称',
warehouse_type VARCHAR(32) NOT NULL DEFAULT 'DOMESTIC' COMMENT '公共仓库类型:DOMESTIC国内仓、FBA亚马逊仓、OVERSEAS普通海外仓、OVERSEAS_PARTNER海外合作仓',
country_code CHAR(2) NOT NULL COMMENT '所在国家二字码',
@@ -865,6 +866,20 @@ CREATE TABLE IF NOT EXISTS wh_warehouse (
KEY idx_wh_country (tenant_id, country_code, deleted)
) ENGINE=InnoDB COMMENT='平台公共仓库主数据表:统一归属平台作用域 tenant_id=0';
SET @warehouse_internal_code_ddl = IF(
EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'baiyue_warehouse'
AND table_name = 'wh_warehouse'
AND column_name = 'internal_code'
),
'SELECT 1',
'ALTER TABLE wh_warehouse ADD COLUMN internal_code VARCHAR(16) NULL COMMENT ''FBA内部编号,仅FBA仓可配置'' AFTER warehouse_code'
);
PREPARE warehouse_internal_code_statement FROM @warehouse_internal_code_ddl;
EXECUTE warehouse_internal_code_statement;
DEALLOCATE PREPARE warehouse_internal_code_statement;
CREATE TABLE IF NOT EXISTS wh_warehouse_address (
id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓库地址固定使用 tenant_id=0',
@@ -5309,6 +5324,29 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1))
) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层';
CREATE TABLE IF NOT EXISTS fl_loading_pallet_label (
id BIGINT UNSIGNED NOT NULL COMMENT '散货托盘标签ID(雪花ID)',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID',
loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '标签归属装柜明细ID',
account_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID快照',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入仓单ID快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
container_no VARCHAR(64) NOT NULL COMMENT '真实集装箱号快照',
destination_warehouse_code VARCHAR(64) NOT NULL COMMENT 'FBA仓编码快照',
destination_warehouse_name VARCHAR(128) NULL COMMENT 'FBA仓名称快照',
pallet_index INT UNSIGNED NOT NULL COMMENT '当前柜当前FBA仓内从1开始的序号',
pallet_code VARCHAR(128) NOT NULL COMMENT '托盘码:集装箱号-FBA仓编码-三位序号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '生成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pallet_code (pallet_code),
UNIQUE KEY uk_loading_pallet_index (loading_plan_id,destination_warehouse_code,pallet_index),
KEY idx_loading_pallet_item (loading_plan_item_id,pallet_index),
KEY idx_loading_pallet_inbound (account_id,inbound_id),
CONSTRAINT fk_loading_pallet_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan(id),
CONSTRAINT fk_loading_pallet_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item(id),
CONSTRAINT chk_loading_pallet_index CHECK (pallet_index BETWEEN 1 AND 999)
) ENGINE=InnoDB COMMENT='散货按柜号和FBA仓冻结的可打印托盘码';
CREATE TABLE IF NOT EXISTS fl_container_document (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
@@ -6395,8 +6433,8 @@ DELIMITER ;
USE baiyue_warehouse;
-- 正式平台公共仓库主数据:固定作业仓 + FBA 地址库。
-- 来源:C:\Users\BY-OY\Downloads\FBA地址表.xls,只读清洗后生成。
-- 接受 252 条,拒绝 54 条;拒绝清单见 master-data/fba-public-addresses.rejected.csv。
-- 来源:用户提供的 FBA地址表.xls,只读整理后生成。
-- 完整保留 314 条,拒绝 0 条;拒绝清单见 master-data/fba-public-addresses.rejected.csv。
-- 四个固定作业仓按通用占位地址写入;清单见 master-data/platform-fixed-warehouses.csv。
-- FBA 是目的地址主数据,不创建 RECEIVING、STORAGE、PICK_PACK、RETURN 等平台作业能力。
@@ -6720,6 +6758,7 @@ CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data (
address_id_seed BIGINT UNSIGNED NOT NULL,
warehouse_code VARCHAR(50) NOT NULL,
warehouse_name VARCHAR(120) NOT NULL,
internal_code VARCHAR(16) NULL,
state_province VARCHAR(100) NOT NULL,
city VARCHAR(100) NOT NULL,
postal_code VARCHAR(20) NOT NULL,
@@ -6732,281 +6771,339 @@ CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data (
) ENGINE=MEMORY DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO tmp_wh_fba_public_master_data
(warehouse_id_seed,address_id_seed,warehouse_code,warehouse_name,state_province,city,postal_code,timezone,address_line1,source_row)
(warehouse_id_seed,address_id_seed,warehouse_code,warehouse_name,internal_code,state_province,city,postal_code,timezone,address_line1,source_row)
VALUES
(193000000000000001,193000000000100001,'ABE2','ABE2','PA','Breinigsville','18031','UTC-5','705 Boulder Drive',238),
(193000000000000002,193000000000100002,'ABE3','ABE3','PA','Breinigsville','18031','UTC-5','650 Boulder Drive',237),
(193000000000000003,193000000000100003,'ABE4','ABE4','PA','Easton','18045','UTC-5','1610 Van Buren Road',134),
(193000000000000004,193000000000100004,'ABE5','ABE5','PA','Harrisburg','17112','UTC-5','6455 Allentown Boulevard',231),
(193000000000000005,193000000000100005,'ABE8','ABE8','NJ','Florence','08518','UTC-5','401 Independence Road 08518-2200',125),
(193000000000000006,193000000000100006,'ABQ2','ABQ2','NM','LOS LUNAS','87031','UTC-7','6251 Pioneer Trail NW',99),
(193000000000000007,193000000000100007,'ACY2','ACY2','NJ','BURLINGTON','08016','UTC-5','1101 E PEARL ST',84),
(193000000000000008,193000000000100008,'AFW1','AFW1','TX','FORT WORTH','76131','UTC-6','1851 NE LOOP 820',141),
(193000000000000009,193000000000100009,'AFW5','AFW5','TX','BALCH SPRINGS','75180-1636','UTC-6','12374 LAKE JUNE RD',27),
(193000000000000010,193000000000100010,'AKR1','AKR1','OH','Canton','44705','UTC-5','4747 Rebar Ave NE',148),
(193000000000000011,193000000000100011,'ALB1','ALB1','NY','Castleton','12033-9634','UTC-5','1835 US Route 9',174),
(193000000000000012,193000000000100012,'AMA1','AMA1','TX','Amarillo','79108','UTC-6','8590 NE 24th Avenue',129),
(193000000000000013,193000000000100013,'ATL2N','ATL2N','GA','Carrollton','30117','UTC-5','3101 N. Highway 27',31),
(193000000000000014,193000000000100014,'ATL3','ATL3','GA','Pendergrass','30567','UTC-5','445 Valentine Industrial Parkway',147),
(193000000000000015,193000000000100015,'ATL6','ATL6','GA','East Point','30344','UTC-5','4200 North Commerce',202),
(193000000000000016,193000000000100016,'AVP1','AVP1','PA','Hazle Township','18202-9361','UTC-5','550 Oak Ridge Road',233),
(193000000000000017,193000000000100017,'AVP3','AVP3','PA','GOULDSBORO','18424-9492','UTC-5','298 1ST AVE',232),
(193000000000000018,193000000000100018,'BDL1','BDL1','CT','Windsor','06095','UTC-5','801 Day Hill Rd.',289),
(193000000000000019,193000000000100019,'BFI1','BFI1','WA','Sumner','98390','UTC-8','1800 140th Avenue E.',208),
(193000000000000020,193000000000100020,'BFI3','BFI3','WA','Dupont','98327-9607','UTC-8','2700 Center Drive',207),
(193000000000000021,193000000000100021,'BFI7','BFI7','WA','Sumner','98390','UTC-8','1901 140th Ave E',206),
(193000000000000022,193000000000100022,'BJC1','BJC1','CO','AURORA','80019','UTC-7','6300 N Powhaton Road',11),
(193000000000000023,193000000000100023,'BNA1','BNA1','TN','Lebanon','37090','UTC-6','14840 Central Pike Suite 190',221),
(193000000000000024,193000000000100024,'BNA2','BNA2','TN','Lebanon','37090','UTC-6','500 Duke DR',131),
(193000000000000025,193000000000100025,'BNA3','BNA3','TN','Murfreesboro','37127','UTC-6','2020 Joe B Jackson Pkwy',140),
(193000000000000026,193000000000100026,'BNA4','BNA4','TN','Nashville','37217','UTC-6','50 airways Blvd',220),
(193000000000000027,193000000000100027,'BNA6','BNA6','TN','CLARKSVILLE','37040-5502','UTC-6','3875 GUTHRIE HWY',110),
(193000000000000028,193000000000100028,'BOS1','BOS1','NH','Nashua','03063','UTC-5','10 State St.',243),
(193000000000000029,193000000000100029,'BOS2','BOS2','MA','Drive Stoughton','02072','UTC-5','1000 Tech Centre',193),
(193000000000000030,193000000000100030,'BOS7','BOS7','MA','Fall River','02720','UTC-5','1180 Innovation Way',192),
(193000000000000031,193000000000100031,'BWI1','BWI1','VA','Sterling','20166-2058','UTC-5','45121 Global Plaza',242),
(193000000000000032,193000000000100032,'BWI2','BWI2','MD','Baltimore','21224','UTC-5','2010 Broening Hwy',241),
(193000000000000033,193000000000100033,'BWI4','BWI4','VA','CLEARBROOK','22624','UTC-5','165 Business Blvd',102),
(193000000000000034,193000000000100034,'CAE1','CAE1','SC','West Columbia','29172','UTC-5','4400 12TH STREET EXT',225),
(193000000000000035,193000000000100035,'CHA1','CHA1','TN','Chattanooga','37416','UTC-5','7200 Discovery Drive',223),
(193000000000000036,193000000000100036,'CHA2','CHA2','TN','Charleston','37310','UTC-5','225 Infinity Dr NW',222),
(193000000000000037,193000000000100037,'CHO1','CHO1','VA','FISHERSVILLE','22939','UTC-5','32 TRADER RD',75),
(193000000000000038,193000000000100038,'CLT2','CLT2','NC','Charlotte','28214-8082','UTC-5','10240 Old Dowd Rd',191),
(193000000000000039,193000000000100039,'CLT3','CLT3','NC','CONCORD','28027','UTC-5','6500 Davidson Hwy',114),
(193000000000000040,193000000000100040,'CMH2','CMH2','OH','Groveport','43125-9016','UTC-5','6050 Gateway Court',240),
(193000000000000041,193000000000100041,'CMH3','CMH3','OH','Monroe','45050-1848','UTC-5','700 GATEWAY BLVD',181),
(193000000000000042,193000000000100042,'CVG1','CVG1','KY','Hebron','41048','UTC-5','1155 Worldwide Blvd',262),
(193000000000000043,193000000000100043,'CVG2','CVG2','KY','Hebron','41048','UTC-5','1600 Worldwide Blvd',261),
(193000000000000044,193000000000100044,'CVG3','CVG3','KY','Hebron','41048','UTC-5','3680 Langley Dr.',260),
(193000000000000045,193000000000100045,'CVG5','CVG5','KY','Hebron','41048','UTC-5','2285 Litton Lane',259),
(193000000000000046,193000000000100046,'DCA6','DCA6','MD','BALTIMORE','21219','UTC-5','6001 Bethlehem Blvd',135),
(193000000000000047,193000000000100047,'DEN2','DEN2','CO','Aurora','80019','UTC-7','22205 East 19th Ave',123),
(193000000000000048,193000000000100048,'DEN8','DEN8','CO','Aurora','80018','UTC-7','21000 E 13th Ave',120),
(193000000000000049,193000000000100049,'DET1','DET1','MI','Livonia','48150-1543','UTC-5','39000 Amrhein Road',189),
(193000000000000050,193000000000100050,'DET2','DET2','MI','Shelby Township','48317-1318','UTC-5','50500 Mound Rd',182),
(193000000000000051,193000000000100051,'DFW5S','DFW5S','TX','Lancaster','75146','UTC-6','2500 E Belt Line Rd,',143),
(193000000000000052,193000000000100052,'DFW6','DFW6','TX','Coppell','75019','UTC-6','940 W Bethel Road',219),
(193000000000000053,193000000000100053,'DFW8','DFW8','TX','DFW Airport','75261','UTC-6','2700 Regent Blvd',217),
(193000000000000054,193000000000100054,'EWR4','EWR4','NJ','Robbinsville','08691','UTC-5','50 New Canton Way',256),
(193000000000000055,193000000000100055,'EWR5','EWR5','NJ','Avenel','07001','UTC-5','301 Blair Rd.',255),
(193000000000000056,193000000000100056,'EWR7','EWR7','NJ','Avenel','07001','UTC-5','301 Blair Rd.',254),
(193000000000000057,193000000000100057,'EWR9','EWR9','NJ','Carteret','07008','UTC-5','8003 Industrial Parkway',253),
(193000000000000058,193000000000100058,'FAT2','FAT2','CA','VISALIA','93291-1882','UTC-8','3315 N KELSEYST',117),
(193000000000000059,193000000000100059,'FOE1','FOE1','KS','KANSAS CITY','66109-3665','UTC-6','9400 LEAVENWORTH RD',176),
(193000000000000060,193000000000100060,'FTW1','FTW1','TX','Dallas','75241','UTC-6','33333 Lyndon B Johnson FREEWAY',212),
(193000000000000061,193000000000100061,'FTW2','FTW2','TX','Coppell','75019','UTC-6','2701 West Bethel Road',211),
(193000000000000062,193000000000100062,'FTW3','FTW3','TX','Fort Worth','76177-2517','UTC-6','15201 Heritage Pkwy',89),
(193000000000000063,193000000000100063,'FTW5','FTW5','TX','Forney','75126','UTC-6','1475 AKRON WAY',121),
(193000000000000064,193000000000100064,'FWA4','FWA4','IN','FORT WAYNE','46809-9771','UTC-5','9798 Smith Road',171),
(193000000000000065,193000000000100065,'GEG2','GEG2','WA','SPOKANE','99216','UTC-8','18007 E Garland Ave',151),
(193000000000000066,193000000000100066,'GEU2','GEU2','AZ','Glendale','85307','UTC-7','15301 West Northern Avenue',67),
(193000000000000067,193000000000100067,'GEU3','GEU3','AZ','BUCKEYE','85396','UTC-7','18900 W McDowell Road',78),
(193000000000000068,193000000000100068,'GEU5','GEU5','AZ','GOODYEAR','85395','UTC-7','4660 North Cotton Lane',68),
(193000000000000069,193000000000100069,'GSO1','GSO1','NC','KERNERSVILLE','27284','UTC-5','1656 OLD GREENSBORO RD',164),
(193000000000000070,193000000000100070,'GSP1','GSP1','SC','Spartanburg','29303','UTC-5','402 John Dodd Rd',224),
(193000000000000071,193000000000100071,'GYR2','GYR2','AZ','GOODYEAR','85395-7672','UTC-7','17341 W MINNEZONA AVE',188),
(193000000000000072,193000000000100072,'GYR3','GYR3','AZ','PHOENIX','85043-2356','UTC-7','8181 W ROOSEVELT ST',187),
(193000000000000073,193000000000100073,'HGR6','HGR6','MD','HAGERSTOWN','21740-7301','UTC-5','55 West Oak Ridge Drive',80),
(193000000000000074,193000000000100074,'HIA1','HIA1','PA','MIDDLETOWN','17057','UTC-5','3327 E Harrisburg Pik',87),
(193000000000000075,193000000000100075,'HLI2','HLI2','CA','HOLLISTER','95023','UTC-8','600 Ernie Drive',44),
(193000000000000076,193000000000100076,'HOU1','HOU1','TX','Humble','77338','UTC-6','8120 Humble Westfield Rd',216),
(193000000000000077,193000000000100077,'HOU2','HOU2','TX','Houston','77038-2324','UTC-6','10550 Ella Blvd.',215),
(193000000000000078,193000000000100078,'HOU3','HOU3','TX','Brookshire','77423','UTC-6','31555 Highway 90 E',214),
(193000000000000079,193000000000100079,'HOU8','HOU8','TX','FRESNO','77545','UTC-6','2303 Hurricane Lane',154),
(193000000000000080,193000000000100080,'HSV1','HSV1','AL','Madison','35756','UTC-6','7817 Greenbrier Road',142),
(193000000000000081,193000000000100081,'HSV2','HSV2','AL','MADISON','35756','UTC-6','28869 Fanning Dr NW',126),
(193000000000000082,193000000000100082,'IAH3','IAH3','TX','Houston','77032','UTC-6','15525 Milner Road',155),
(193000000000000083,193000000000100083,'IAZ1','IAZ1','AZ','GOODYEAR','85395-4515','UTC-7','4660 N COTTON LN 85395-4515 - GOODYEAR, AZ - United States',3),
(193000000000000084,193000000000100084,'ICT2','ICT2','KS','PARK CITY','67219','UTC-6','7130 N BROADWAY AVE',136),
(193000000000000085,193000000000100085,'IGA3','IGA3','GA','MACON','31216-6427','UTC-5','7001 Skipper Rd 31216-6427 MACON, GA - United States',2),
(193000000000000086,193000000000100086,'IGQ2','IGQ2','IL','University park','60484','UTC-6','23257 S. Central Avenue',152),
(193000000000000087,193000000000100087,'ILG1','ILG1','DE','NEW CASTLE','19720','UTC-5','780 S. DuPont Highway',119),
(193000000000000088,193000000000100088,'IMO1','IMO1','MO','LIBERTY','64068-8933','UTC-6','2361 S Withers RD',10),
(193000000000000089,193000000000100089,'IMS1','IMS1','MS','OLIVE BRANCH','38654','UTC-6','IMS1-11505 Progress Way,38654-2510,OLIVE BRANCH,MS,US',6),
(193000000000000090,193000000000100090,'INC1','INC1','NC','FAYETTEVILLE','28303-2215','UTC-5','6309 Bragg Blvd 28303-2215 - FAYETTEVILLE, NC - United States',5),
(193000000000000091,193000000000100091,'IND2','IND2','IN','Plainfield','46168-7442','UTC-5','715 Airtech Pkwy',88),
(193000000000000092,193000000000100092,'IND4','IND4','IN','Indianapolis','46231','UTC-5','710 S. Girls School Rd',276),
(193000000000000093,193000000000100093,'IND5','IND5','IN','Plainfield','46168','UTC-5','800 Perry Road',275),
(193000000000000094,193000000000100094,'IND6','IND6','IN','Jeffersonville','47130','UTC-5','1453 E 10th St',274),
(193000000000000095,193000000000100095,'IND9','IND9','IN','GREENWOOD','46143-7830','UTC-5','1151 S GRAHAM RD',173),
(193000000000000096,193000000000100096,'ITX3','ITX3','TX','AMARILLO','79108-7749','UTC-6','ITX3-8590 NE 24th Ave,79108-7749,AMARILLO,TX,US',9),
(193000000000000097,193000000000100097,'IUSF','IUSF','TX','DALLAS','75241','UTC-6','3351 Balmorhea Dr',22),
(193000000000000098,193000000000100098,'IUSJ','IUSJ','CA','BEAUMONT','92223','UTC-8','36900 West 4th Street',69),
(193000000000000099,193000000000100099,'IUSP','IUSP','CA','HESPERIA,','92344','UTC-8','8140 Caliente Rd',74),
(193000000000000100,193000000000100100,'IUSQ','IUSQ','CA','REDLANDS','92374','UTC-8','2125 W San Bernardino Ave',70),
(193000000000000101,193000000000100101,'IUSR','IUSR','SC','COWPENS','29330','UTC-5','1120 Mt Olive Rd',40),
(193000000000000102,193000000000100102,'IUST','IUST','PA','GREENCASTLE','17225','UTC-5','3PL AWD (Greencastle, PA, US) 15651 Greenmount RdGREENCASTLE, PA 17225 United States',39),
(193000000000000103,193000000000100103,'IUSW','IUSW','CA','Hesperia','92344','UTC-8','8130 Caliente Rd 92344 - Hesperia, CA - United States',33),
(193000000000000104,193000000000100104,'IUTE','IUTE','AZ','GOODYEAR','85338','UTC-7','16155 W Elwood St',21),
(193000000000000105,193000000000100105,'IUTJ','IUTJ','CA','Fontana','92335','UTC-8','9253 Dreamland Drive 92335 - Fontana, CA - United',8),
(193000000000000106,193000000000100106,'IUTL','IUTL','CA','Moreno Valley','92551','UTC-8','24405 Krameria Ave 92551 - Moreno Valley, CA - United States',7),
(193000000000000107,193000000000100107,'IWA6','IWA6','WA','PASCO','99301-6161','UTC-8','1202 S Road 40 E 99301-6161 - PASCO, WA - United States',4),
(193000000000000108,193000000000100108,'JOT1','JOT1','CA','SAN BERNARDINO','92408','UTC-8','1494 S WATERMAN AVE',72),
(193000000000000109,193000000000100109,'JVL1','JVL1','WI','BELOIT','53511','UTC-6','1255 Gateway Blvd',150),
(193000000000000110,193000000000100110,'KRB4','KRB4','CA','PERRIS','92571-9722','UTC-8','4501 PATTERSON AVE',178),
(193000000000000111,193000000000100111,'KY1','KY1','KY','Shepherdsville','40165','UTC-5','120 Velocity Way.,',161),
(193000000000000112,193000000000100112,'LAN2','LAN2','MI','LANSING','48917','UTC-5','6500 W Mt Hope Hwy',100),
(193000000000000113,193000000000100113,'LAS1','LAS1','NV','HENDERSON','89044','UTC-8','12300 Bermuda Road',247),
(193000000000000114,193000000000100114,'LAS2','LAS2','NV','Las Vegas','89030','UTC-8','3837 Bay Lake Trail, Suite 111 North',246),
(193000000000000115,193000000000100115,'LAS6','LAS6','NV','Las Vegas','89115','UTC-8','4550 Nexus Way',132),
(193000000000000116,193000000000100116,'LAX2T','LAX2T','CA','CHINO','91708','UTC-8','6730 Kimball Ave',162),
(193000000000000117,193000000000100117,'LAX9','LAX9','CA','FONTANA','92337','UTC-8','11263 Oleander Ave',183),
(193000000000000118,193000000000100118,'LBE1','LBE1','PA','Greensburg','15605','UTC-5','165 Glen Fox Road',86),
(193000000000000119,193000000000100119,'LEX1','LEX1','KY','Lexington','40511','UTC-5','1850 Mercer RD',268),
(193000000000000120,193000000000100120,'LEX2','LEX2','KY','Lexington','40511','UTC-5','172 Trade St.',267),
(193000000000000121,193000000000100121,'LFT1','LFT1','LA','CARENCRO','70520-5976','UTC-6','3550 NE EVANGELINE TRWY',107),
(193000000000000122,193000000000100122,'LGB3','LGB3','CA','Eastvale','91752','UTC-8','4950 Goodman Way',291),
(193000000000000123,193000000000100123,'LGB4','LGB4','CA','Redlands','92374','UTC-8','27517 Pioneer Avenue',292),
(193000000000000124,193000000000100124,'LGB6','LGB6','CA','Riverside','92518-1513','UTC-8','20901 Krameria Ave',293),
(193000000000000125,193000000000100125,'LGB8','LGB8','CA','Rialto','92376','UTC-8','1568 N Linden Ave',294),
(193000000000000126,193000000000100126,'LIT2','LIT2','AR','NORTH LITTLE ROCK','72117','UTC-6','13001 HIGHWAY 70',106),
(193000000000000127,193000000000100127,'MCC1','MCC1','CA','RANCHO CORDOVA','95742','UTC-8','3300 Rancho Cordova Pkwy 95742 - RANCHO CORDOVA, CA - United States',32),
(193000000000000128,193000000000100128,'MCE1','MCE1','CA','TURLOCK','95380','UTC-8','FULKERTH RD',157),
(193000000000000129,193000000000100129,'MCI1','MCI1','KS','Lenexa','66219','UTC-6','113th St & Renner Blvd.',271),
(193000000000000130,193000000000100130,'MCI4','MCI4','MO','KANSAS CITY','64163','UTC-6','MCI4 - 10501 NW 136th Street',12),
(193000000000000131,193000000000100131,'MCO2','MCO2','FL','Deltona','32725-2610','UTC-5','2600 N Normandy Blvd',115),
(193000000000000132,193000000000100132,'MDT1','MDT1','PA','Carlisle','17015','UTC-5','2Ames Drive',230),
(193000000000000133,193000000000100133,'MDT4','MDT4','PA','York','17408','UTC-5','5125 Commerce Drive',128),
(193000000000000134,193000000000100134,'MDW2','MDW2','IL','Joliet','60433-3280','UTC-6','250 Emerald Drive',200),
(193000000000000135,193000000000100135,'MDW6','MDW6','IL','ROMEOVILLE','60446-6529','UTC-6','1125 W REMINGTON BLVD ROMEOVILLE',199),
(193000000000000136,193000000000100136,'MDW7','MDW7','IL','Monee','60449','UTC-6','6605 Monee Manhattan Road',198),
(193000000000000137,193000000000100137,'MDW8','MDW8','IL','Waukegan','60085','UTC-6','1750 Bridge Drive',197),
(193000000000000138,193000000000100138,'MDW9','MDW9','IL','Aurora','60502','UTC-6','2865 Duke Parkway',196),
(193000000000000139,193000000000100139,'MEM1','MEM1','TN','Memphis','38118','UTC-6','3292 E Holmes Rd',163),
(193000000000000140,193000000000100140,'MEM6','MEM6','MS','OLIVE BRANCH','38654','UTC-6','11505 Progress Way',118),
(193000000000000141,193000000000100141,'MGE3','MGE3','GA','Jefferson','30549-2010','UTC-5','808 Hog Mountain Road',201),
(193000000000000142,193000000000100142,'MIT2','MIT2','CA','SHAFTER','93263','UTC-8','5408 Express Avenue',77),
(193000000000000143,193000000000100143,'MKC4','MKC4','KS','Edgerton','66021-9588','UTC-6','19645 Waverly Rd',270),
(193000000000000144,193000000000100144,'MKC6','MKC6','KS','Kansas City','66102','UTC-6','6925 Riverview Avenue',269),
(193000000000000145,193000000000100145,'MKE1','MKE1','WI','Kenosha','53144','UTC-6','3501 120th Ave',203),
(193000000000000146,193000000000100146,'MQJ1','MQJ1','IN','GREENFIELD','46140','UTC-5','4412 W 300 RD N',94),
(193000000000000147,193000000000100147,'MSP1','MSP1','MN','Shakopee','55379','UTC-6','2601 4th Avenue East',190),
(193000000000000148,193000000000100148,'NJ3','NJ3','NJ','Pedricktown','08067','UTC-5','3 Sorbello Way',17),
(193000000000000149,193000000000100149,'OAK3','OAK3','CA','Patterson','95363-8876','UTC-8','255 Park Center Drive',299),
(193000000000000150,193000000000100150,'OAK4','OAK4','CA','Tracy','95304-9370','UTC-8','1555 N. Chrisman Rd',300),
(193000000000000151,193000000000100151,'OKC2','OKC2','OK','OKLAHOMA CITY','73159','UTC-6','8991 S PORTLAND AVENUE',133),
(193000000000000152,193000000000100152,'OLM1','OLM1','WA','OLYMPIA','98516','UTC-8','3300 HOGUM BAY RD NE 98516-3111 - OLYMPIA, WA - United States',19),
(193000000000000153,193000000000100153,'ONT2','ONT2','CA','San Bernardino','92408','UTC-8','1910 E Central Ave',303),
(193000000000000154,193000000000100154,'ONT6','ONT6','CA','Moreno Valley','92551-9561','UTC-8','24208 San Michele Rd',302),
(193000000000000155,193000000000100155,'ONT8','ONT8','CA','Moreno Valley','92551-9534','UTC-8','24300 Nandina Ave',301),
(193000000000000156,193000000000100156,'ONT9','ONT9','CA','Redlands','92374','UTC-8','2125 W. San Bernandino Ave',298),
(193000000000000157,193000000000100157,'ORD2','ORD2','IL','CHANNAHON','60410-8758','UTC-6','23714 W Amoco Rd',103),
(193000000000000158,193000000000100158,'ORD6','ORD6','IL','Wood Dale','60191','UTC-6','1250 N Mittel Blvd',195),
(193000000000000159,193000000000100159,'ORF2','ORF2','VA','CHESAPEAKE','23321-1425','UTC-5','ORF2 - 5045 PORTSMOUTH BLVD',113),
(193000000000000160,193000000000100160,'PBI3','PBI3','FL','Jacksonville','32206','UTC-5','21 Jump Street',81),
(193000000000000161,193000000000100161,'PDX7','PDX7','OR','Salem','97317','UTC-8','4775 Depot Ct SE',127),
(193000000000000162,193000000000100162,'PHL1','PHL1','DE','New Castle','19720','UTC-5','1 Centerpoint Blvd.',288),
(193000000000000163,193000000000100163,'PHL3','PHL3','DE','New Castle','19720','UTC-5','1600 Johnson Way',287),
(193000000000000164,193000000000100164,'PHL4','PHL4','PA','Carlisle','17015','UTC-5','21 Roadway Dr',236),
(193000000000000165,193000000000100165,'PHL5','PHL5','PA','Lewisberry','17339','UTC-5','500 McCarthy Dr',235),
(193000000000000166,193000000000100166,'PHL5S','PHL5S','PA','Greencastle','17225','UTC-5','1915 Ebberts Spg Ct, Greencastle, PA 17225',20),
(193000000000000167,193000000000100167,'PHL6','PHL6','PA','Carlisle','17015','UTC-5','675 Allen Rd.',234),
(193000000000000168,193000000000100168,'PHL7','PHL7','DE','Middletown','19709','UTC-5','560 Merrimac Ave',286),
(193000000000000169,193000000000100169,'PHL8','PHL8','DE','Middletown','19709','UTC-5','727 N Broad St',285),
(193000000000000170,193000000000100170,'PHX3','PHX3','AZ','Phoenix','85043','UTC-7','6835 West Buckeye Road',307),
(193000000000000171,193000000000100171,'PHX5','PHX5','AZ','Goodyear','85338','UTC-7','16920 W Commerce Drive',306),
(193000000000000172,193000000000100172,'PHX6','PHX6','AZ','Phoenix','85043','UTC-7','4750 West Mohave St',305),
(193000000000000173,193000000000100173,'PHX7','PHX7','AZ','Phoenix','85043','UTC-7','800 N 75th Ave',304),
(193000000000000174,193000000000100174,'PIT1','PIT1','PA','Pittsburgh','15205','UTC-5','2250 Roswell Drive',229),
(193000000000000175,193000000000100175,'PIT2','PIT2','PA','IMPERIAL','15126-1002','UTC-5','1200 Westport Rd',158),
(193000000000000176,193000000000100176,'POC1','POC1','CA','FONTANA','92336-1123','UTC-8','4121 LYTLE CREEK RD',46),
(193000000000000177,193000000000100177,'POC2','POC2','CA','ONTARIO','91761','UTC-8','4000 South Hamner Avenue',38),
(193000000000000178,193000000000100178,'POC3','POC3','CA','JURUPA VALLEY','92509','UTC-8','6120 Clinker Drive',45),
(193000000000000179,193000000000100179,'PPO4','PPO4','IN','MERRILLVILLE','46410','UTC-6','9850 Mississippi Street',49),
(193000000000000180,193000000000100180,'PSC2','PSC2','WA','PASCO','99301','UTC-8','1351 S Road 40 E',93),
(193000000000000181,193000000000100181,'PSP3','PSP3','CA','DESERT HOT SPRINGS','92240','UTC-8','64165 19th AVE',28),
(193000000000000182,193000000000100182,'QXY5','QXY5','CA','STOCKTON','95215','UTC-8','4532 NEWCASTLE RD',71),
(193000000000000183,193000000000100183,'QXY8','QXY8','CA','FRESNO','93725-1962','UTC-8','3611 S NORTHPOINTE DR',35),
(193000000000000184,193000000000100184,'QZZ7','QZZ7','AZ','MESA','85212-9608','UTC-7','8560 E ELLIOT RD',14),
(193000000000000185,193000000000100185,'RDU2','RDU2','NC','Smithfield','27577','UTC-5','2150 US HWY 70 Business West',146),
(193000000000000186,193000000000100186,'RDU4','RDU4','NC','FAYETTEVILLE','28303','UTC-5','6309 Bragg Blvd',95),
(193000000000000187,193000000000100187,'RFD2','RFD2','IL','Huntley','60142','UTC-6','11500 Freeman Road',145),
(193000000000000188,193000000000100188,'RFD4','RFD4','IL','HUNTLEY','60142','UTC-6','11400 Venture Court 60142-8075 - HUNTLEY, IL - United States',16),
(193000000000000189,193000000000100189,'RIC1','RIC1','VA','Petersburg','23803','UTC-5','5000 Commerce Way',210),
(193000000000000190,193000000000100190,'RIC2','RIC2','VA','Chester','23836','UTC-5','1901 Meadowville Technology Pkwy',209),
(193000000000000191,193000000000100191,'RMN3','RMN3','VA','Fredericksburg','22406','UTC-5','220 Centreport Parkway',144),
(193000000000000192,193000000000100192,'RNO1','RNO1','NV','Fernley','89408','UTC-8','1600 East Newlands RD',248),
(193000000000000193,193000000000100193,'RNO2','RNO2','NV','Reno','85906','UTC-8','8000 North Virginia Street',245),
(193000000000000194,193000000000100194,'RNO4','RNO4','NV','Reno','89506','UTC-8','8000 North Virginia Street',244),
(193000000000000195,193000000000100195,'RYY2','RYY2','GA','White','30184','UTC-5','1250 Cassville White Road,',73),
(193000000000000196,193000000000100196,'SAT1','SAT1','TX','Schertz','78154-1461','UTC-6','6000 Enterprise Avenue',218),
(193000000000000197,193000000000100197,'SAT4','SAT4','TX','SAN ANTONIO','78245','UTC-6','10384 W US HIGHWAY 90',149),
(193000000000000198,193000000000100198,'SAT6','SAT6','TX','SAN MARCOS','78666','UTC-6','1346 Fortuna Road 78666-0001 - SAN MARCOS, TX - United States',15),
(193000000000000199,193000000000100199,'SAV3','SAV3','GA','Macon','31216-6427','UTC-5','7001 Skipper Rd',98),
(193000000000000200,193000000000100200,'SBD1','SBD1','CA','BLOOMINGTON','92316-3819','UTC-8','3388 S Cactus Ave',185),
(193000000000000201,193000000000100201,'SBD2','SBD2','CA','SAN BERNARDINO','92408-2805','UTC-8','1494 S WATERMAN AVE',179),
(193000000000000202,193000000000100202,'SCK1','SCK1','CA','STOCKTON','95215','UTC-8','4532 NEWCASTLE RD',97),
(193000000000000203,193000000000100203,'SCK3','SCK3','CA','MANTECA','95336','UTC-8','3565 N AIRPORT WAY MANTECA',60),
(193000000000000204,193000000000100204,'SCK4','SCK4','CA','STOCKTON','95215-8354','UTC-8','6001 S AUSTIN RD',186),
(193000000000000205,193000000000100205,'SCK8','SCK8','CA','OAKLEY','94561-2677','UTC-8','4700 WILBUR AVE',104),
(193000000000000206,193000000000100206,'SDF1','SDF1','KY','Campbellsville','42718','UTC-5','1050 South Columbia AVE',266),
(193000000000000207,193000000000100207,'SDF2','SDF2','KY','Watterson Park','40218','UTC-5','4360 Robards Ln',265),
(193000000000000208,193000000000100208,'SDF4','SDF4','KY','Shepherdsville','40165','UTC-5','376 Zappos.com Blvd',264),
(193000000000000209,193000000000100209,'SDF6','SDF6','KY','Shepherdsville','40165','UTC-5','271 Omega Pkwy',263),
(193000000000000210,193000000000100210,'SDF7','SDF7','KY','Shepherdsville','40165','UTC-5','300 Omicron Court',258),
(193000000000000211,193000000000100211,'SDF8','SDF8','IN','Jeffersonville','47130','UTC-5','900 Patrol Rd',273),
(193000000000000212,193000000000100212,'SDF9','SDF9','KY','Shepherdsville','40165','UTC-5','100 W. Thomas P. Echols Lane',257),
(193000000000000213,193000000000100213,'SEA6','SEA6','WA','South Seattle','98144','UTC-8','2646 Rainier Ave.',205),
(193000000000000214,193000000000100214,'SEA8','SEA8','WA','Northeast Bellevue','98005','UTC-8','1475 124th Avenue',204),
(193000000000000215,193000000000100215,'SJC7','SJC7','CA','Tracy','95377-8906','UTC-8','188 Mountain House Parkway',295),
(193000000000000216,193000000000100216,'SLC2','SLC2','UT','West Jordan','84081','UTC-7','7148 W. Old Bingham Hwy',153),
(193000000000000217,193000000000100217,'SLC3','SLC3','UT','SALT LAKE CITY','84116-4413','UTC-7','355 N JOHN GLENN RD',13),
(193000000000000218,193000000000100218,'SMF3','SMF3','CA','Stockton','95206-8202','UTC-8','3923 S B ST',184),
(193000000000000219,193000000000100219,'SMF6','SMF6','CA','SACRAMENTO','95837-9109','UTC-8','4930 ALLBAUGH DR',105),
(193000000000000220,193000000000100220,'SNA4','SNA4','CA','Rialto','92376-3009','UTC-8','2496 W Walnut Ave',296),
(193000000000000221,193000000000100221,'STL3','STL3','MO','REPUBLIC','65738-2779','UTC-6','3200 E SAWYER RD',109),
(193000000000000222,193000000000100222,'STL4','STL4','IL','Edwardsville','62025-2815','UTC-6','3050 GATEWAY COMMERCE CENTER DR S',194),
(193000000000000223,193000000000100223,'SWF1','SWF1','NY','ROCK TAVERN','12575','UTC-5','635 INTERNATIONAL BLVD',122),
(193000000000000224,193000000000100224,'SWF2','SWF2','NY','Hopewell Junction','12533','UTC-5','76 Patriot Way Hopewell Junction',76),
(193000000000000225,193000000000100225,'TCY1','TCY1','CA','STOCKTON','95206','UTC-8','2690 East Arch Airport Road',41),
(193000000000000226,193000000000100226,'TCY2','TCY2','CA','STOCKTON','95215','UTC-8','6201 Newcastle Rd',43),
(193000000000000227,193000000000100227,'TEB3','TEB3','NJ','Logan Township','08085','UTC-5','2651 Oldmans Creek Rd',250),
(193000000000000228,193000000000100228,'TEB4','TEB4','NJ','PENNS GROVE','08069','UTC-5','747 COURSES LANDING RD',130),
(193000000000000229,193000000000100229,'TEB6','TEB6','NJ','Cranbury','08512','UTC-5','22 Hightstown Cranbury State Road',249),
(193000000000000230,193000000000100230,'TEB9','TEB9','NJ','Somerset','08873','UTC-5','601 Randolph Road',156),
(193000000000000231,193000000000100231,'TMB8','TMB8','FL','HOMESTEAD','33032-8597','UTC-5','27505 SW 132nd AVE',65),
(193000000000000232,193000000000100232,'TOL3','TOL3','OH','PERRYSBURG','43551','UTC-5','9240 Fremont Pike 43551-4210 - PERRYSBURG, OH - United States',18),
(193000000000000233,193000000000100233,'TPA1','TPA1','FL','Ruskin','33570','UTC-5','3350 Laurel Ridge Ave.',284),
(193000000000000234,193000000000100234,'TPA2','TPA2','FL','Lakeland','33811','UTC-5','1760 County Line Rd',283),
(193000000000000235,193000000000100235,'TPA3','TPA3','FL','Auburndale','33823','UTC-5','676 C. Fred Jones Boulevard',124),
(193000000000000236,193000000000100236,'TUL1','TUL1','KS','Coffeyville','67337','UTC-6','2654 North Highway 169',272),
(193000000000000237,193000000000100237,'VGT2','VGT2','NV','LAS VEGAS','89115-2013','UTC-8','6401 Howdy Wells Ave',172),
(193000000000000238,193000000000100238,'VUBA','VUBA','PA','Scranton','18512','UTC-5','1000 Keystone Industrial Park',228),
(193000000000000239,193000000000100239,'VUGA','VUGA','PA','Pittston','18643','UTC-5','508 Delaware Avenue West',227),
(193000000000000240,193000000000100240,'WBW2','WBW2','PA','OLYPHANT','18447','UTC-5','1300 Corporate Way',82),
(193000000000000241,193000000000100241,'XAV3','XAV3','GA','PORT WENTWORTH','31407','UTC-5','XAV3-120Crossgate Dr 31407-Port Wentworth, GA- United States',36),
(193000000000000242,193000000000100242,'XEW5','XEW5','NJ','Cinnaminson','08077-2015','UTC-5','995 Taylors Ln 08077-2015',37),
(193000000000000243,193000000000100243,'XIN5','XIN5','IN','Avon','46123-1700','UTC-5','8838 E COUNTY ROAD 100 S',25),
(193000000000000244,193000000000100244,'XLX1','XLX1','MD','HAGERSTOWN','21740','UTC-5','1115 Wesel Blvd HAGERSTOWN,MD 21740,USA',34),
(193000000000000245,193000000000100245,'XLX6','XLX6','NC','PINEVILLE','28134','UTC-5','12220 Carolina Logistics Drive PINEVILLE,NC 28134,USA',23),
(193000000000000246,193000000000100246,'XLX7','XLX7','CA','VICTORVILLE','92394','UTC-8','18580 Gateway Dr,Victorville,CA 92394,USA',63),
(193000000000000247,193000000000100247,'XPB2','XPB2','FL','APOPKA','32712','UTC-5','XPB2 - 4661 Apopka Logistics Pkwy 32712 - Apopka, FL - United States',26),
(193000000000000248,193000000000100248,'XRI3','XRI3','VA','Richmond','23234','UTC-5','4949 Commerce Rd, Richmond, VA 23234',24),
(193000000000000249,193000000000100249,'XUSB','XUSB','TX','Fort Worth','76155','UTC-6','14900 Frye Rd.',213),
(193000000000000250,193000000000100250,'XUSC','XUSC','PA','Carlisle','17013','UTC-5','40 Logistics Drive',226),
(193000000000000251,193000000000100251,'XUSD','XUSD','CA','Stockton','95206','UTC-8','1909 Zephyr St',297),
(193000000000000252,193000000000100252,'XUSE','XUSE','IN','Whitestown','46075','UTC-5','5100 S Indianapolis Rd',280);
(193000000000000001,193000000000100001,'ABE2','ABE2',NULL,'PA','Breinigsville','18031','UTC-5','705 Boulder Drive',246),
(193000000000000002,193000000000100002,'ABE3','ABE3',NULL,'PA','Breinigsville','18031','UTC-5','650 Boulder Drive',245),
(193000000000000003,193000000000100003,'ABE4','ABE4','126','PA','Easton','18045','UTC-5','1610 Van Buren Road',142),
(193000000000000004,193000000000100004,'ABE5','ABE5',NULL,'PA','Harrisburg','17112','UTC-5','6455 Allentown Boulevard',239),
(193000000000000005,193000000000100005,'ABE8','ABE8','39','NJ','Florence','08518','UTC-5','401 Independence Road 08518-2200',133),
(193000000000000253,193000000000100253,'ABE8-309','ABE8-309',NULL,'NJ','Florence','08518-2802','UTC-5','309 Cedar Lane',259),
(193000000000000254,193000000000100254,'ABE8-401','ABE8-401',NULL,'NJ','Florence','08518','UTC-5','401 Independence Road',260),
(193000000000000006,193000000000100006,'ABQ2','ABQ2','76','NM','LOS LUNAS','87031','UTC-7','6251 Pioneer Trail NW',107),
(193000000000000255,193000000000100255,'ABQ2-UPS','ABQ2-UPS',NULL,'AZ','GOODYEAR','85395','UTC-7','16601 W. SELLS DRIVE',104),
(193000000000000007,193000000000100007,'ACY2','ACY2','101','NJ','BURLINGTON','08016','UTC-5','1101 E PEARL ST',92),
(193000000000000008,193000000000100008,'AFW1','AFW1',NULL,'TX','FORT WORTH','76131','UTC-6','1851 NE LOOP 820',149),
(193000000000000009,193000000000100009,'AFW5','AFW5','181','TX','BALCH SPRINGS','75180-1636','UTC-6','12374 LAKE JUNE RD',35),
(193000000000000010,193000000000100010,'AKR1','AKR1','153','OH','Canton','44705','UTC-5','4747 Rebar Ave NE',156),
(193000000000000011,193000000000100011,'ALB1','ALB1','102','NY','Castleton','12033-9634','UTC-5','1835 US Route 9',182),
(193000000000000012,193000000000100012,'AMA1','AMA1','132','TX','Amarillo','79108','UTC-6','8590 NE 24th Avenue',137),
(193000000000000256,193000000000100256,'ATL1','ATL1','200','GA','Atlanta','30349','UTC-5','ATL1, 6055 S Fulton Pkwy, Atlanta, GA, 30349',2),
(193000000000000257,193000000000100257,'ATL1-WALMART','ATL1-WALMART',NULL,'GA','Atlanta','30349','UTC-5','6055 S Fulton Pkwy,',146),
(193000000000000013,193000000000100013,'ATL2N','ATL2N','157','GA','Carrollton','30117','UTC-5','3101 N. Highway 27',39),
(193000000000000014,193000000000100014,'ATL3','ATL3',NULL,'GA','Pendergrass','30567','UTC-5','445 Valentine Industrial Parkway',155),
(193000000000000015,193000000000100015,'ATL6','ATL6',NULL,'GA','East Point','30344','UTC-5','4200 North Commerce',210),
(193000000000000016,193000000000100016,'AVP1','AVP1','70','PA','Hazle Township','18202-9361','UTC-5','550 Oak Ridge Road',241),
(193000000000000017,193000000000100017,'AVP3','AVP3',NULL,'PA','GOULDSBORO','18424-9492','UTC-5','298 1ST AVE',240),
(193000000000000258,193000000000100258,'AZA4','AZA4','16','','','','','',72),
(193000000000000018,193000000000100018,'BDL1','BDL1',NULL,'CT','Windsor','06095','UTC-5','801 Day Hill Rd.',297),
(193000000000000019,193000000000100019,'BFI1','BFI1',NULL,'WA','Sumner','98390','UTC-8','1800 140th Avenue E.',216),
(193000000000000020,193000000000100020,'BFI3','BFI3','34','WA','Dupont','98327-9607','UTC-8','2700 Center Drive',215),
(193000000000000021,193000000000100021,'BFI7','BFI7',NULL,'WA','Sumner','98390','UTC-8','1901 140th Ave E',214),
(193000000000000022,193000000000100022,'BJC1','BJC1','186','CO','AURORA','80019','UTC-7','6300 N Powhaton Road',19),
(193000000000000023,193000000000100023,'BNA1','BNA1',NULL,'TN','Lebanon','37090','UTC-6','14840 Central Pike Suite 190',229),
(193000000000000024,193000000000100024,'BNA2','BNA2','37','TN','Lebanon','37090','UTC-6','500 Duke DR',139),
(193000000000000025,193000000000100025,'BNA3','BNA3',NULL,'TN','Murfreesboro','37127','UTC-6','2020 Joe B Jackson Pkwy',148),
(193000000000000026,193000000000100026,'BNA4','BNA4',NULL,'TN','Nashville','37217','UTC-6','50 airways Blvd',228),
(193000000000000027,193000000000100027,'BNA6','BNA6','99','TN','CLARKSVILLE','37040-5502','UTC-6','3875 GUTHRIE HWY',118),
(193000000000000028,193000000000100028,'BOS1','BOS1',NULL,'NH','Nashua','03063','UTC-5','10 State St.',251),
(193000000000000029,193000000000100029,'BOS2','BOS2',NULL,'MA','Drive Stoughton','02072','UTC-5','1000 Tech Centre',201),
(193000000000000030,193000000000100030,'BOS7','BOS7','103','MA','Fall River','02720','UTC-5','1180 Innovation Way',200),
(193000000000000031,193000000000100031,'BWI1','BWI1',NULL,'VA','Sterling','20166-2058','UTC-5','45121 Global Plaza',250),
(193000000000000032,193000000000100032,'BWI2','BWI2',NULL,'MD','Baltimore','21224','UTC-5','2010 Broening Hwy',249),
(193000000000000033,193000000000100033,'BWI4','BWI4','150','VA','CLEARBROOK','22624','UTC-5','165 Business Blvd',110),
(193000000000000034,193000000000100034,'CAE1','CAE1',NULL,'SC','West Columbia','29172','UTC-5','4400 12TH STREET EXT',233),
(193000000000000259,193000000000100259,'CEG2','CEG2','79','','','','','',64),
(193000000000000035,193000000000100035,'CHA1','CHA1',NULL,'TN','Chattanooga','37416','UTC-5','7200 Discovery Drive',231),
(193000000000000036,193000000000100036,'CHA2','CHA2','66','TN','Charleston','37310','UTC-5','225 Infinity Dr NW',230),
(193000000000000037,193000000000100037,'CHO1','CHO1','120','VA','FISHERSVILLE','22939','UTC-5','32 TRADER RD',83),
(193000000000000038,193000000000100038,'CLT2','CLT2','59','NC','Charlotte','28214-8082','UTC-5','10240 Old Dowd Rd',199),
(193000000000000039,193000000000100039,'CLT3','CLT3','104','NC','CONCORD','28027','UTC-5','6500 Davidson Hwy',122),
(193000000000000260,193000000000100260,'CMH1-11999','CMH1-11999',NULL,'OH','Etna','43062-7793','UTC-5','11999 National Road SW',247),
(193000000000000040,193000000000100040,'CMH2','CMH2','123','OH','Groveport','43125-9016','UTC-5','6050 Gateway Court',248),
(193000000000000041,193000000000100041,'CMH3','CMH3','32','OH','Monroe','45050-1848','UTC-5','700 GATEWAY BLVD',189),
(193000000000000042,193000000000100042,'CVG1','CVG1',NULL,'KY','Hebron','41048','UTC-5','1155 Worldwide Blvd',270),
(193000000000000043,193000000000100043,'CVG2','CVG2',NULL,'KY','Hebron','41048','UTC-5','1600 Worldwide Blvd',269),
(193000000000000044,193000000000100044,'CVG3','CVG3',NULL,'KY','Hebron','41048','UTC-5','3680 Langley Dr.',268),
(193000000000000045,193000000000100045,'CVG5','CVG5',NULL,'KY','Hebron','41048','UTC-5','2285 Litton Lane',267),
(193000000000000046,193000000000100046,'DCA6','DCA6','135','MD','BALTIMORE','21219','UTC-5','6001 Bethlehem Blvd',143),
(193000000000000047,193000000000100047,'DEN2','DEN2','107','CO','Aurora','80019','UTC-7','22205 East 19th Ave',131),
(193000000000000048,193000000000100048,'DEN8','DEN8','73','CO','Aurora','80018','UTC-7','21000 E 13th Ave',128),
(193000000000000049,193000000000100049,'DET1','DET1','118','MI','Livonia','48150-1543','UTC-5','39000 Amrhein Road',197),
(193000000000000050,193000000000100050,'DET2','DET2','136','MI','Shelby Township','48317-1318','UTC-5','50500 Mound Rd',190),
(193000000000000051,193000000000100051,'DFW5S','DFW5S',NULL,'TX','Lancaster','75146','UTC-6','2500 E Belt Line Rd,',151),
(193000000000000052,193000000000100052,'DFW6','DFW6','105','TX','Coppell','75019','UTC-6','940 W Bethel Road',227),
(193000000000000053,193000000000100053,'DFW8','DFW8',NULL,'TX','DFW Airport','75261','UTC-6','2700 Regent Blvd',225),
(193000000000000054,193000000000100054,'EWR4','EWR4',NULL,'NJ','Robbinsville','08691','UTC-5','50 New Canton Way',264),
(193000000000000055,193000000000100055,'EWR5','EWR5',NULL,'NJ','Avenel','07001','UTC-5','301 Blair Rd.',263),
(193000000000000056,193000000000100056,'EWR7','EWR7',NULL,'NJ','Avenel','07001','UTC-5','301 Blair Rd.',262),
(193000000000000057,193000000000100057,'EWR9','EWR9',NULL,'NJ','Carteret','07008','UTC-5','8003 Industrial Parkway',261),
(193000000000000058,193000000000100058,'FAT2','FAT2','43','CA','VISALIA','93291-1882','UTC-8','3315 N KELSEYST',125),
(193000000000000261,193000000000100261,'FEDEX','FEDEX','12','','','92337','','10767',55),
(193000000000000059,193000000000100059,'FOE1','FOE1','117','KS','KANSAS CITY','66109-3665','UTC-6','9400 LEAVENWORTH RD',184),
(193000000000000060,193000000000100060,'FTW1','FTW1','24','TX','Dallas','75241','UTC-6','33333 Lyndon B Johnson FREEWAY',220),
(193000000000000061,193000000000100061,'FTW2','FTW2',NULL,'TX','Coppell','75019','UTC-6','2701 West Bethel Road',219),
(193000000000000062,193000000000100062,'FTW3','FTW3',NULL,'TX','Fort Worth','76177-2517','UTC-6','15201 Heritage Pkwy',97),
(193000000000000063,193000000000100063,'FTW5','FTW5','48','TX','Forney','75126','UTC-6','1475 AKRON WAY',129),
(193000000000000064,193000000000100064,'FWA4','FWA4','58','IN','FORT WAYNE','46809-9771','UTC-5','9798 Smith Road',179),
(193000000000000065,193000000000100065,'GEG2','GEG2','108','WA','SPOKANE','99216','UTC-8','18007 E Garland Ave',159),
(193000000000000066,193000000000100066,'GEU2','GEU2','97','AZ','Glendale','85307','UTC-7','15301 West Northern Avenue',75),
(193000000000000067,193000000000100067,'GEU3','GEU3','81','AZ','BUCKEYE','85396','UTC-7','18900 W McDowell Road',86),
(193000000000000068,193000000000100068,'GEU5','GEU5','100','AZ','GOODYEAR','85395','UTC-7','4660 North Cotton Lane',76),
(193000000000000069,193000000000100069,'GSO1','GSO1','131','NC','KERNERSVILLE','27284','UTC-5','1656 OLD GREENSBORO RD',172),
(193000000000000070,193000000000100070,'GSP1','GSP1','90','SC','Spartanburg','29303','UTC-5','402 John Dodd Rd',232),
(193000000000000071,193000000000100071,'GYR2','GYR2','7','AZ','GOODYEAR','85395-7672','UTC-7','17341 W MINNEZONA AVE',196),
(193000000000000072,193000000000100072,'GYR3','GYR3','8','AZ','PHOENIX','85043-2356','UTC-7','8181 W ROOSEVELT ST',195),
(193000000000000262,193000000000100262,'HEA2','HEA2','77','','','18031','','650 BOULDER DR Suite A 18031-1536 - BREINIGSVILLE, PA - United States',65),
(193000000000000073,193000000000100073,'HGR6','HGR6','130','MD','HAGERSTOWN','21740-7301','UTC-5','55 West Oak Ridge Drive',88),
(193000000000000074,193000000000100074,'HIA1','HIA1','148','PA','MIDDLETOWN','17057','UTC-5','3327 E Harrisburg Pik',95),
(193000000000000075,193000000000100075,'HLI2','HLI2','137','CA','HOLLISTER','95023','UTC-8','600 Ernie Drive',52),
(193000000000000076,193000000000100076,'HOU1','HOU1',NULL,'TX','Humble','77338','UTC-6','8120 Humble Westfield Rd',224),
(193000000000000077,193000000000100077,'HOU2','HOU2',NULL,'TX','Houston','77038-2324','UTC-6','10550 Ella Blvd.',223),
(193000000000000078,193000000000100078,'HOU3','HOU3',NULL,'TX','Brookshire','77423','UTC-6','31555 Highway 90 E',222),
(193000000000000263,193000000000100263,'HOU7','HOU7','63','','','','','',67),
(193000000000000079,193000000000100079,'HOU8','HOU8','49','TX','FRESNO','77545','UTC-6','2303 Hurricane Lane',162),
(193000000000000080,193000000000100080,'HSV1','HSV1','80','AL','Madison','35756','UTC-6','7817 Greenbrier Road',150),
(193000000000000081,193000000000100081,'HSV2','HSV2',NULL,'AL','MADISON','35756','UTC-6','28869 Fanning Dr NW',134),
(193000000000000082,193000000000100082,'IAH3','IAH3','57','TX','Houston','77032','UTC-6','15525 Milner Road',163),
(193000000000000264,193000000000100264,'IAK3','IAK3','44','','','','','',70),
(193000000000000083,193000000000100083,'IAZ1','IAZ1','179','AZ','GOODYEAR','85395-4515','UTC-7','4660 N COTTON LN 85395-4515 - GOODYEAR, AZ - United States',11),
(193000000000000084,193000000000100084,'ICT2','ICT2','109','KS','PARK CITY','67219','UTC-6','7130 N BROADWAY AVE',144),
(193000000000000085,193000000000100085,'IGA3','IGA3','197','GA','MACON','31216-6427','UTC-5','7001 Skipper Rd 31216-6427 MACON, GA - United States',10),
(193000000000000086,193000000000100086,'IGQ2','IGQ2','110','IL','University park','60484','UTC-6','23257 S. Central Avenue',160),
(193000000000000087,193000000000100087,'ILG1','ILG1','35','DE','NEW CASTLE','19720','UTC-5','780 S. DuPont Highway',127),
(193000000000000265,193000000000100265,'IMI1','IMI1','199','MI','LANSING','48917-8539','UTC-5','6500 W Mt Hope Hwy 48917-8539 - LANSING, MI - United States',9),
(193000000000000088,193000000000100088,'IMO1','IMO1','178','MO','LIBERTY','64068-8933','UTC-6','2361 S Withers RD',18),
(193000000000000089,193000000000100089,'IMS1','IMS1','189','MS','OLIVE BRANCH','38654','UTC-6','IMS1-11505 Progress Way,38654-2510,OLIVE BRANCH,MS,US',14),
(193000000000000090,193000000000100090,'INC1','INC1','190','NC','FAYETTEVILLE','28303-2215','UTC-5','6309 Bragg Blvd 28303-2215 - FAYETTEVILLE, NC - United States',13),
(193000000000000266,193000000000100266,'IND1-AMAZON','IND1-AMAZON',NULL,'IN','Whitestown','46075','UTC-5','4255 Anson Blvd',287),
(193000000000000267,193000000000100267,'IND1-WALMART','IND1-WALMART',NULL,'IN','Avon','46123','UTC-5','9590 AllPoints Pkwy',176),
(193000000000000091,193000000000100091,'IND2','IND2','127','IN','Plainfield','46168-7442','UTC-5','715 Airtech Pkwy',96),
(193000000000000268,193000000000100268,'IND2-AMAZON','IND2-AMAZON',NULL,'IN','Plainfield','46168','UTC-5','715 Airtech Pkwy',286),
(193000000000000269,193000000000100269,'IND2-WALMART','IND2-WALMART',NULL,'IN','Avon','46123','UTC-5','9360 AllPoints Pkwy',177),
(193000000000000270,193000000000100270,'IND3','IND3','198','IN','Mccordsville','46055','UTC-5','5756 Enterprise Dr',6),
(193000000000000271,193000000000100271,'IND3-亚马逊','IND3-亚马逊',NULL,'IN','Pkwy Plainfield','46168','','717 Airtech Pkwy Plainfield IN 46168,USA',147),
(193000000000000272,193000000000100272,'IND3-沃尔玛','IND3-沃尔玛',NULL,'IN','Mccordsville','46055','UTC-5','5756 Enterprise Dr, Mccordsville, IN 46055',285),
(193000000000000092,193000000000100092,'IND4','IND4',NULL,'IN','Indianapolis','46231','UTC-5','710 S. Girls School Rd',284),
(193000000000000093,193000000000100093,'IND5','IND5','140','IN','Plainfield','46168','UTC-5','800 Perry Road',283),
(193000000000000094,193000000000100094,'IND6','IND6',NULL,'IN','Jeffersonville','47130','UTC-5','1453 E 10th St',282),
(193000000000000095,193000000000100095,'IND9','IND9','17','IN','GREENWOOD','46143-7830','UTC-5','1151 S GRAHAM RD',181),
(193000000000000273,193000000000100273,'IPA1','IPA1','194','PA','OLYPHANT','18447-2140','UTC-5','1300 Corporate Way',3),
(193000000000000096,193000000000100096,'ITX3','ITX3','187','TX','AMARILLO','79108-7749','UTC-6','ITX3-8590 NE 24th Ave,79108-7749,AMARILLO,TX,US',17),
(193000000000000097,193000000000100097,'IUSF','IUSF','169','TX','DALLAS','75241','UTC-6','3351 Balmorhea Dr',30),
(193000000000000098,193000000000100098,'IUSJ','IUSJ','98','CA','BEAUMONT','92223','UTC-8','36900 West 4th Street',77),
(193000000000000099,193000000000100099,'IUSP','IUSP','83','CA','HESPERIA,','92344','UTC-8','8140 Caliente Rd',82),
(193000000000000274,193000000000100274,'IUSP-AWD','IUSP-AWD','83','CA','HESPERIA','92344','UTC-8','8140 Caliente Rd',91),
(193000000000000100,193000000000100100,'IUSQ','IUSQ','95','CA','REDLANDS','92374','UTC-8','2125 W San Bernardino Ave',78),
(193000000000000101,193000000000100101,'IUSR','IUSR','158','SC','COWPENS','29330','UTC-5','1120 Mt Olive Rd',48),
(193000000000000102,193000000000100102,'IUST','IUST','149','PA','GREENCASTLE','17225','UTC-5','3PL AWD (Greencastle, PA, US) 15651 Greenmount RdGREENCASTLE, PA 17225 United States',47),
(193000000000000103,193000000000100103,'IUSW','IUSW','156','CA','Hesperia','92344','UTC-8','8130 Caliente Rd 92344 - Hesperia, CA - United States',41),
(193000000000000104,193000000000100104,'IUTE','IUTE','170','AZ','GOODYEAR','85338','UTC-7','16155 W Elwood St',29),
(193000000000000105,193000000000100105,'IUTJ','IUTJ','177','CA','Fontana','92335','UTC-8','9253 Dreamland Drive 92335 - Fontana, CA - United',16),
(193000000000000106,193000000000100106,'IUTL','IUTL','188','CA','Moreno Valley','92551','UTC-8','24405 Krameria Ave 92551 - Moreno Valley, CA - United States',15),
(193000000000000107,193000000000100107,'IWA6','IWA6','191','WA','PASCO','99301-6161','UTC-8','1202 S Road 40 E 99301-6161 - PASCO, WA - United States',12),
(193000000000000275,193000000000100275,'JAX3','JAX3','111','','','32210','','JAX3-32210',58),
(193000000000000276,193000000000100276,'JAX3-32210','JAX3-32210','111','FL','Jacksonville','32210-8686','UTC-5','13333 103rd Street',289),
(193000000000000277,193000000000100277,'JAX3-32221','JAX3-32221',NULL,'FL','Jacksonville','32221','UTC-5','13333 103rd Street',175),
(193000000000000108,193000000000100108,'JOT1','JOT1','93','CA','SAN BERNARDINO','92408','UTC-8','1494 S WATERMAN AVE',80),
(193000000000000109,193000000000100109,'JVL1','JVL1','125','WI','BELOIT','53511','UTC-6','1255 Gateway Blvd',158),
(193000000000000110,193000000000100110,'KRB4','KRB4',NULL,'CA','PERRIS','92571-9722','UTC-8','4501 PATTERSON AVE',186),
(193000000000000111,193000000000100111,'KY1','KY1','60','KY','Shepherdsville','40165','UTC-5','120 Velocity Way.,',169),
(193000000000000112,193000000000100112,'LAN2','LAN2','96','MI','LANSING','48917','UTC-5','6500 W Mt Hope Hwy',108),
(193000000000000278,193000000000100278,'LAN2-48917','LAN2-48917',NULL,'MI','LANSING','48917','UTC-5','6500 W Mt Hope Hwy',109),
(193000000000000113,193000000000100113,'LAS1','LAS1','5','NV','HENDERSON','89044','UTC-8','12300 Bermuda Road',255),
(193000000000000114,193000000000100114,'LAS2','LAS2',NULL,'NV','Las Vegas','89030','UTC-8','3837 Bay Lake Trail, Suite 111 North',254),
(193000000000000115,193000000000100115,'LAS6','LAS6','50','NV','Las Vegas','89115','UTC-8','4550 Nexus Way',140),
(193000000000000279,193000000000100279,'LAX1','LAX1','192','CA','Chino','91708','UTC-8','6750 Kimball Avenue, Chino,CA 91708,USA',8),
(193000000000000280,193000000000100280,'LAX1-WALMART','LAX1-WALMART',NULL,'CA','Chino','91708','UTC-8','6750 Kimball Avenue',185),
(193000000000000116,193000000000100116,'LAX2T','LAX2T','19','CA','CHINO','91708','UTC-8','6730 Kimball Ave',170),
(193000000000000281,193000000000100281,'LAX2T-WALMART','LAX2T-WALMART',NULL,'CA','Chino','91708','UTC-8','6720 Kimball Ave',188),
(193000000000000117,193000000000100117,'LAX9','LAX9','3','CA','FONTANA','92337','UTC-8','11263 Oleander Ave',191),
(193000000000000118,193000000000100118,'LBE1','LBE1','112','PA','Greensburg','15605','UTC-5','165 Glen Fox Road',94),
(193000000000000282,193000000000100282,'LBE1-UPS','LBE1-UPS',NULL,'PA','New Stanton','15672','UTC-5','165 GLENN FOX RD',93),
(193000000000000119,193000000000100119,'LEX1','LEX1',NULL,'KY','Lexington','40511','UTC-5','1850 Mercer RD',276),
(193000000000000120,193000000000100120,'LEX2','LEX2',NULL,'KY','Lexington','40511','UTC-5','172 Trade St.',275),
(193000000000000121,193000000000100121,'LFT1','LFT1','31','LA','CARENCRO','70520-5976','UTC-6','3550 NE EVANGELINE TRWY',115),
(193000000000000122,193000000000100122,'LGB3','LGB3',NULL,'CA','Eastvale','91752','UTC-8','4950 Goodman Way',299),
(193000000000000123,193000000000100123,'LGB4','LGB4','45','CA','Redlands','92374','UTC-8','27517 Pioneer Avenue',300),
(193000000000000124,193000000000100124,'LGB6','LGB6','26','CA','Riverside','92518-1513','UTC-8','20901 Krameria Ave',301),
(193000000000000125,193000000000100125,'LGB8','LGB8','2','CA','Rialto','92376','UTC-8','1568 N Linden Ave',302),
(193000000000000126,193000000000100126,'LIT2','LIT2','159','AR','NORTH LITTLE ROCK','72117','UTC-6','13001 HIGHWAY 70',114),
(193000000000000127,193000000000100127,'MCC1','MCC1','161','CA','RANCHO CORDOVA','95742','UTC-8','3300 Rancho Cordova Pkwy 95742 - RANCHO CORDOVA, CA - United States',40),
(193000000000000128,193000000000100128,'MCE1','MCE1','65','CA','TURLOCK','95380','UTC-8','FULKERTH RD',165),
(193000000000000129,193000000000100129,'MCI1','MCI1',NULL,'KS','Lenexa','66219','UTC-6','113th St & Renner Blvd.',279),
(193000000000000130,193000000000100130,'MCI4','MCI4','176','MO','KANSAS CITY','64163','UTC-6','MCI4 - 10501 NW 136th Street',20),
(193000000000000283,193000000000100283,'MCO1-WALMART','MCO1-WALMART',NULL,'FL','Davenport','33897','UTC-5','5100 NORTH RIDGE TRAIL',116),
(193000000000000131,193000000000100131,'MCO2','MCO2','106','FL','Deltona','32725-2610','UTC-5','2600 N Normandy Blvd',123),
(193000000000000132,193000000000100132,'MDT1','MDT1','121','PA','Carlisle','17015','UTC-5','2Ames Drive',238),
(193000000000000133,193000000000100133,'MDT4','MDT4','144','PA','York','17408','UTC-5','5125 Commerce Drive',136),
(193000000000000134,193000000000100134,'MDW2','MDW2','46','IL','Joliet','60433-3280','UTC-6','250 Emerald Drive',208),
(193000000000000284,193000000000100284,'MDW2-60433-3280','MDW2-60433-3280',NULL,'IL','Joliet','60433-3280','UTC-6','250 Emerald Drive',119),
(193000000000000135,193000000000100135,'MDW6','MDW6','143','IL','ROMEOVILLE','60446-6529','UTC-6','1125 W REMINGTON BLVD ROMEOVILLE',207),
(193000000000000136,193000000000100136,'MDW7','MDW7',NULL,'IL','Monee','60449','UTC-6','6605 Monee Manhattan Road',206),
(193000000000000137,193000000000100137,'MDW8','MDW8',NULL,'IL','Waukegan','60085','UTC-6','1750 Bridge Drive',205),
(193000000000000138,193000000000100138,'MDW9','MDW9','36','IL','Aurora','60502','UTC-6','2865 Duke Parkway',204),
(193000000000000139,193000000000100139,'MEM1','MEM1','85','TN','Memphis','38118','UTC-6','3292 E Holmes Rd',171),
(193000000000000285,193000000000100285,'MEM1S-WALMART','MEM1S-WALMART',NULL,'MS','OLIVE BRANCH','38654','UTC-6','9200 Alexander Road',183),
(193000000000000140,193000000000100140,'MEM6','MEM6','51','MS','OLIVE BRANCH','38654','UTC-6','11505 Progress Way',126),
(193000000000000141,193000000000100141,'MGE3','MGE3','133','GA','Jefferson','30549-2010','UTC-5','808 Hog Mountain Road',209),
(193000000000000286,193000000000100286,'MIA1-33054','MIA1-33054',NULL,'FL','Opa-Locka','33054','UTC-5','14000 NW 37th Avenue',174),
(193000000000000287,193000000000100287,'MIA1-33182','MIA1-33182',NULL,'FL','Miami','33182','UTC-5','1900 NW 132nd Place Doral',290),
(193000000000000142,193000000000100142,'MIT2','MIT2','82','CA','SHAFTER','93263','UTC-8','5408 Express Avenue',85),
(193000000000000143,193000000000100143,'MKC4','MKC4','67','KS','Edgerton','66021-9588','UTC-6','19645 Waverly Rd',278),
(193000000000000144,193000000000100144,'MKC6','MKC6',NULL,'KS','Kansas City','66102','UTC-6','6925 Riverview Avenue',277),
(193000000000000145,193000000000100145,'MKE1','MKE1',NULL,'WI','Kenosha','53144','UTC-6','3501 120th Ave',211),
(193000000000000146,193000000000100146,'MQJ1','MQJ1','88','IN','GREENFIELD','46140','UTC-5','4412 W 300 RD N',102),
(193000000000000288,193000000000100288,'MQY2','MQY2','193','TN','LEBANON','37090','UTC-6','MQY2 - 1301 Richard Petty Way 37090 - LEBANON, TN - United States',7),
(193000000000000147,193000000000100147,'MSP1','MSP1',NULL,'MN','Shakopee','55379','UTC-6','2601 4th Avenue East',198),
(193000000000000148,193000000000100148,'NJ3','NJ3','175','NJ','Pedricktown','08067','UTC-5','3 Sorbello Way',25),
(193000000000000289,193000000000100289,'NJ3-WALMART','NJ3-WALMART',NULL,'NJ','Pedricktown','08067','UTC-5','3 Sorbello Way',87),
(193000000000000290,193000000000100290,'NJ3T','NJ3T','195','NJ','Pedricktown','08067','UTC-5','7 Sorbello Rd',5),
(193000000000000149,193000000000100149,'OAK3','OAK3','41','CA','Patterson','95363-8876','UTC-8','255 Park Center Drive',307),
(193000000000000150,193000000000100150,'OAK4','OAK4',NULL,'CA','Tracy','95304-9370','UTC-8','1555 N. Chrisman Rd',308),
(193000000000000151,193000000000100151,'OKC2','OKC2','33','OK','OKLAHOMA CITY','73159','UTC-6','8991 S PORTLAND AVENUE',141),
(193000000000000152,193000000000100152,'OLM1','OLM1','172','WA','OLYMPIA','98516','UTC-8','3300 HOGUM BAY RD NE 98516-3111 - OLYMPIA, WA - United States',27),
(193000000000000153,193000000000100153,'ONT2','ONT2',NULL,'CA','San Bernardino','92408','UTC-8','1910 E Central Ave',311),
(193000000000000154,193000000000100154,'ONT6','ONT6',NULL,'CA','Moreno Valley','92551-9561','UTC-8','24208 San Michele Rd',310),
(193000000000000155,193000000000100155,'ONT8','ONT8','1','CA','Moreno Valley','92551-9534','UTC-8','24300 Nandina Ave',309),
(193000000000000156,193000000000100156,'ONT9','ONT9','25','CA','Redlands','92374','UTC-8','2125 W. San Bernandino Ave',306),
(193000000000000291,193000000000100291,'ORD1S-WALMART','ORD1S-WALMART',NULL,'IL','Joliet','60436','UTC-6','3501 Brandon Road',99),
(193000000000000157,193000000000100157,'ORD2','ORD2','21','IL','CHANNAHON','60410-8758','UTC-6','23714 W Amoco Rd',111),
(193000000000000158,193000000000100158,'ORD6','ORD6',NULL,'IL','Wood Dale','60191','UTC-6','1250 N Mittel Blvd',203),
(193000000000000159,193000000000100159,'ORF2','ORF2','38','VA','CHESAPEAKE','23321-1425','UTC-5','ORF2 - 5045 PORTSMOUTH BLVD',121),
(193000000000000292,193000000000100292,'ORF2-FEDEX','ORF2-FEDEX',NULL,'VA','CHESAPEAKE','23321-1425','UTC-5','ORF2 - 5045 PORTSMOUTH BLVD',167),
(193000000000000293,193000000000100293,'ORF2-UPS','ORF2-UPS',NULL,'VA','Richmond','23297','UTC-5','9603 Coach Road',124),
(193000000000000160,193000000000100160,'PBI3','PBI3','113','FL','Jacksonville','32206','UTC-5','21 Jump Street',89),
(193000000000000161,193000000000100161,'PDX7','PDX7','68','OR','Salem','97317','UTC-8','4775 Depot Ct SE',135),
(193000000000000162,193000000000100162,'PHL1','PHL1',NULL,'DE','New Castle','19720','UTC-5','1 Centerpoint Blvd.',296),
(193000000000000163,193000000000100163,'PHL3','PHL3',NULL,'DE','New Castle','19720','UTC-5','1600 Johnson Way',295),
(193000000000000164,193000000000100164,'PHL4','PHL4','52','PA','Carlisle','17015','UTC-5','21 Roadway Dr',244),
(193000000000000165,193000000000100165,'PHL5','PHL5',NULL,'PA','Lewisberry','17339','UTC-5','500 McCarthy Dr',243),
(193000000000000166,193000000000100166,'PHL5S','PHL5S','171','PA','Greencastle','17225','UTC-5','1915 Ebberts Spg Ct, Greencastle, PA 17225',28),
(193000000000000294,193000000000100294,'PHL5S-WALMART','PHL5S-WALMART',NULL,'PA','Greencastle','17225','UTC-5','1915 Ebberts Spg Ct',98),
(193000000000000167,193000000000100167,'PHL6','PHL6','69','PA','Carlisle','17015','UTC-5','675 Allen Rd.',242),
(193000000000000168,193000000000100168,'PHL7','PHL7',NULL,'DE','Middletown','19709','UTC-5','560 Merrimac Ave',294),
(193000000000000169,193000000000100169,'PHL8','PHL8',NULL,'DE','Middletown','19709','UTC-5','727 N Broad St',293),
(193000000000000170,193000000000100170,'PHX3','PHX3',NULL,'AZ','Phoenix','85043','UTC-7','6835 West Buckeye Road',315),
(193000000000000171,193000000000100171,'PHX5','PHX5','72','AZ','Goodyear','85338','UTC-7','16920 W Commerce Drive',314),
(193000000000000172,193000000000100172,'PHX6','PHX6',NULL,'AZ','Phoenix','85043','UTC-7','4750 West Mohave St',313),
(193000000000000173,193000000000100173,'PHX7','PHX7','74','AZ','Phoenix','85043','UTC-7','800 N 75th Ave',312),
(193000000000000174,193000000000100174,'PIT1','PIT1',NULL,'PA','Pittsburgh','15205','UTC-5','2250 Roswell Drive',237),
(193000000000000175,193000000000100175,'PIT2','PIT2','78','PA','IMPERIAL','15126-1002','UTC-5','1200 Westport Rd',166),
(193000000000000176,193000000000100176,'POC1','POC1','128','CA','FONTANA','92336-1123','UTC-8','4121 LYTLE CREEK RD',54),
(193000000000000177,193000000000100177,'POC2','POC2','151','CA','ONTARIO','91761','UTC-8','4000 South Hamner Avenue',46),
(193000000000000178,193000000000100178,'POC3','POC3','129','CA','JURUPA VALLEY','92509','UTC-8','6120 Clinker Drive',53),
(193000000000000179,193000000000100179,'PPO4','PPO4','122','IN','MERRILLVILLE','46410','UTC-6','9850 Mississippi Street',57),
(193000000000000180,193000000000100180,'PSC2','PSC2','75','WA','PASCO','99301','UTC-8','1351 S Road 40 E',101),
(193000000000000295,193000000000100295,'PSC2-UPS','PSC2-UPS',NULL,'OR','Portland','97242','UTC-8','9930 NE Alderwood Rd',100),
(193000000000000181,193000000000100181,'PSP3','PSP3','162','CA','DESERT HOT SPRINGS','92240','UTC-8','64165 19th AVE',36),
(193000000000000182,193000000000100182,'QXY5','QXY5','92','CA','STOCKTON','95215','UTC-8','4532 NEWCASTLE RD',79),
(193000000000000183,193000000000100183,'QXY8','QXY8','154','CA','FRESNO','93725-1962','UTC-8','3611 S NORTHPOINTE DR',43),
(193000000000000184,193000000000100184,'QZZ7','QZZ7','184','AZ','MESA','85212-9608','UTC-7','8560 E ELLIOT RD',22),
(193000000000000185,193000000000100185,'RDU2','RDU2','56','NC','Smithfield','27577','UTC-5','2150 US HWY 70 Business West',154),
(193000000000000186,193000000000100186,'RDU4','RDU4','145','NC','FAYETTEVILLE','28303','UTC-5','6309 Bragg Blvd',103),
(193000000000000187,193000000000100187,'RFD2','RFD2','71','IL','Huntley','60142','UTC-6','11500 Freeman Road',153),
(193000000000000188,193000000000100188,'RFD4','RFD4','174','IL','HUNTLEY','60142','UTC-6','11400 Venture Court 60142-8075 - HUNTLEY, IL - United States',24),
(193000000000000189,193000000000100189,'RIC1','RIC1','142','VA','Petersburg','23803','UTC-5','5000 Commerce Way',218),
(193000000000000190,193000000000100190,'RIC2','RIC2',NULL,'VA','Chester','23836','UTC-5','1901 Meadowville Technology Pkwy',217),
(193000000000000191,193000000000100191,'RMN3','RMN3','84','VA','Fredericksburg','22406','UTC-5','220 Centreport Parkway',152),
(193000000000000192,193000000000100192,'RNO1','RNO1',NULL,'NV','Fernley','89408','UTC-8','1600 East Newlands RD',256),
(193000000000000193,193000000000100193,'RNO2','RNO2',NULL,'NV','Reno','85906','UTC-8','8000 North Virginia Street',253),
(193000000000000194,193000000000100194,'RNO4','RNO4',NULL,'NV','Reno','89506','UTC-8','8000 North Virginia Street',252),
(193000000000000195,193000000000100195,'RYY2','RYY2','94','GA','White','30184','UTC-5','1250 Cassville White Road,',81),
(193000000000000196,193000000000100196,'SAT1','SAT1','23','TX','Schertz','78154-1461','UTC-6','6000 Enterprise Avenue',226),
(193000000000000197,193000000000100197,'SAT4','SAT4','61','TX','SAN ANTONIO','78245','UTC-6','10384 W US HIGHWAY 90',157),
(193000000000000198,193000000000100198,'SAT6','SAT6','183','TX','SAN MARCOS','78666','UTC-6','1346 Fortuna Road 78666-0001 - SAN MARCOS, TX - United States',23),
(193000000000000199,193000000000100199,'SAV3','SAV3','124','GA','Macon','31216-6427','UTC-5','7001 Skipper Rd',106),
(193000000000000200,193000000000100200,'SBD1','SBD1','4','CA','BLOOMINGTON','92316-3819','UTC-8','3388 S Cactus Ave',193),
(193000000000000201,193000000000100201,'SBD2','SBD2','15','CA','SAN BERNARDINO','92408-2805','UTC-8','1494 S WATERMAN AVE',187),
(193000000000000202,193000000000100202,'SCK1','SCK1','20','CA','STOCKTON','95215','UTC-8','4532 NEWCASTLE RD',105),
(193000000000000203,193000000000100203,'SCK3','SCK3','54','CA','MANTECA','95336','UTC-8','3565 N AIRPORT WAY MANTECA',68),
(193000000000000204,193000000000100204,'SCK4','SCK4','10','CA','STOCKTON','95215-8354','UTC-8','6001 S AUSTIN RD',194),
(193000000000000205,193000000000100205,'SCK8','SCK8','141','CA','OAKLEY','94561-2677','UTC-8','4700 WILBUR AVE',112),
(193000000000000206,193000000000100206,'SDF1','SDF1',NULL,'KY','Campbellsville','42718','UTC-5','1050 South Columbia AVE',274),
(193000000000000207,193000000000100207,'SDF2','SDF2',NULL,'KY','Watterson Park','40218','UTC-5','4360 Robards Ln',273),
(193000000000000208,193000000000100208,'SDF4','SDF4',NULL,'KY','Shepherdsville','40165','UTC-5','376 Zappos.com Blvd',272),
(193000000000000209,193000000000100209,'SDF6','SDF6',NULL,'KY','Shepherdsville','40165','UTC-5','271 Omega Pkwy',271),
(193000000000000210,193000000000100210,'SDF7','SDF7',NULL,'KY','Shepherdsville','40165','UTC-5','300 Omicron Court',266),
(193000000000000211,193000000000100211,'SDF8','SDF8',NULL,'IN','Jeffersonville','47130','UTC-5','900 Patrol Rd',281),
(193000000000000212,193000000000100212,'SDF9','SDF9',NULL,'KY','Shepherdsville','40165','UTC-5','100 W. Thomas P. Echols Lane',265),
(193000000000000213,193000000000100213,'SEA6','SEA6',NULL,'WA','South Seattle','98144','UTC-8','2646 Rainier Ave.',213),
(193000000000000214,193000000000100214,'SEA8','SEA8',NULL,'WA','Northeast Bellevue','98005','UTC-8','1475 124th Avenue',212),
(193000000000000215,193000000000100215,'SJC7','SJC7','30','CA','Tracy','95377-8906','UTC-8','188 Mountain House Parkway',303),
(193000000000000296,193000000000100296,'SLC1N','SLC1N','196','UT','Salt Lake City','84116','UTC-7','990 N 6550 W',4),
(193000000000000216,193000000000100216,'SLC2','SLC2','86','UT','West Jordan','84081','UTC-7','7148 W. Old Bingham Hwy',161),
(193000000000000217,193000000000100217,'SLC3','SLC3','185','UT','SALT LAKE CITY','84116-4413','UTC-7','355 N JOHN GLENN RD',21),
(193000000000000297,193000000000100297,'SMF1-AMAZON','SMF1-AMAZON',NULL,'CA','Sacramento','95835-9505','UTC-8','4900 W Elkhorn Blvd',298),
(193000000000000298,193000000000100298,'SMF1-WALMART','SMF1-WALMART',NULL,'CA','Sacramento','95837','UTC-8','7000 Powerline Rd',173),
(193000000000000218,193000000000100218,'SMF3','SMF3','9','CA','Stockton','95206-8202','UTC-8','3923 S B ST',192),
(193000000000000219,193000000000100219,'SMF6','SMF6','28','CA','SACRAMENTO','95837-9109','UTC-8','4930 ALLBAUGH DR',113),
(193000000000000299,193000000000100299,'SMF7','SMF7','53','','','','','',69),
(193000000000000220,193000000000100220,'SNA4','SNA4','18','CA','Rialto','92376-3009','UTC-8','2496 W Walnut Ave',304),
(193000000000000221,193000000000100221,'STL3','STL3','152','MO','REPUBLIC','65738-2779','UTC-6','3200 E SAWYER RD',117),
(193000000000000222,193000000000100222,'STL4','STL4','115','IL','Edwardsville','62025-2815','UTC-6','3050 GATEWAY COMMERCE CENTER DR S',202),
(193000000000000300,193000000000100300,'STL6','STL6','64','','','','','',66),
(193000000000000223,193000000000100223,'SWF1','SWF1','134','NY','ROCK TAVERN','12575','UTC-5','635 INTERNATIONAL BLVD',130),
(193000000000000224,193000000000100224,'SWF2','SWF2','62','NY','Hopewell Junction','12533','UTC-5','76 Patriot Way Hopewell Junction',84),
(193000000000000301,193000000000100301,'SWF2-10710','SWF2-10710',NULL,'NY','Yonkers','10710','UTC-5','557 Tuckahoe Road',145),
(193000000000000302,193000000000100302,'SWF2-12533','SWF2-12533',NULL,'NY','Hopewell Junction','12533-6159','UTC-5','76 Patriot Way',168),
(193000000000000225,193000000000100225,'TCY1','TCY1','139','CA','STOCKTON','95206','UTC-8','2690 East Arch Airport Road',49),
(193000000000000226,193000000000100226,'TCY2','TCY2','138','CA','STOCKTON','95215','UTC-8','6201 Newcastle Rd',51),
(193000000000000227,193000000000100227,'TEB3','TEB3','47','NJ','Logan Township','08085','UTC-5','2651 Oldmans Creek Rd',258),
(193000000000000228,193000000000100228,'TEB4','TEB4','55','NJ','PENNS GROVE','08069','UTC-5','747 COURSES LANDING RD',138),
(193000000000000229,193000000000100229,'TEB6','TEB6','114','NJ','Cranbury','08512','UTC-5','22 Hightstown Cranbury State Road',257),
(193000000000000230,193000000000100230,'TEB9','TEB9','40','NJ','Somerset','08873','UTC-5','601 Randolph Road',164),
(193000000000000231,193000000000100231,'TMB8','TMB8','116','FL','HOMESTEAD','33032-8597','UTC-5','27505 SW 132nd AVE',73),
(193000000000000303,193000000000100303,'TMB8-UPS','TMB8-UPS',NULL,'FL','Miami Gardens','33055','UTC-5','8675309 Jenny Lane',74),
(193000000000000232,193000000000100232,'TOL3','TOL3','173','OH','PERRYSBURG','43551','UTC-5','9240 Fremont Pike 43551-4210 - PERRYSBURG, OH - United States',26),
(193000000000000233,193000000000100233,'TPA1','TPA1',NULL,'FL','Ruskin','33570','UTC-5','3350 Laurel Ridge Ave.',292),
(193000000000000234,193000000000100234,'TPA2','TPA2','22','FL','Lakeland','33811','UTC-5','1760 County Line Rd',291),
(193000000000000235,193000000000100235,'TPA3','TPA3','146','FL','Auburndale','33823','UTC-5','676 C. Fred Jones Boulevard',132),
(193000000000000236,193000000000100236,'TUL1','TUL1',NULL,'KS','Coffeyville','67337','UTC-6','2654 North Highway 169',280),
(193000000000000304,193000000000100304,'UPS','UPS','11','','','92337','','10767',56),
(193000000000000237,193000000000100237,'VGT2','VGT2','6','NV','LAS VEGAS','89115-2013','UTC-8','6401 Howdy Wells Ave',180),
(193000000000000305,193000000000100305,'VGT2-FEDEX','VGT2-FEDEX',NULL,'NV','LAS VEGAS','89115-201','UTC-8','6401 Howdy Wells Ave',120),
(193000000000000306,193000000000100306,'VGT2-UPS','VGT2-UPS',NULL,'CA','March Air Reserve Base','92518','UTC-8','20701 Krameria Ave',178),
(193000000000000238,193000000000100238,'VUBA','VUBA',NULL,'PA','Scranton','18512','UTC-5','1000 Keystone Industrial Park',236),
(193000000000000239,193000000000100239,'VUGA','VUGA',NULL,'PA','Pittston','18643','UTC-5','508 Delaware Avenue West',235),
(193000000000000240,193000000000100240,'WBW2','WBW2','119','PA','OLYPHANT','18447','UTC-5','1300 Corporate Way',90),
(193000000000000241,193000000000100241,'XAV3','XAV3','180','GA','PORT WENTWORTH','31407','UTC-5','XAV3-120Crossgate Dr 31407-Port Wentworth, GA- United States',44),
(193000000000000307,193000000000100307,'XBN3','XBN3','91','','','','','',61),
(193000000000000308,193000000000100308,'XCH2','XCH2','87','','','','','',63),
(193000000000000242,193000000000100242,'XEW5','XEW5','163','NJ','Cinnaminson','08077-2015','UTC-5','995 Taylors Ln 08077-2015',45),
(193000000000000243,193000000000100243,'XIN5','XIN5','182','IN','Avon','46123-1700','UTC-5','8838 E COUNTY ROAD 100 S',33),
(193000000000000244,193000000000100244,'XLX1','XLX1','160','MD','HAGERSTOWN','21740','UTC-5','1115 Wesel Blvd HAGERSTOWN,MD 21740,USA',42),
(193000000000000245,193000000000100245,'XLX6','XLX6','166','NC','PINEVILLE','28134','UTC-5','12220 Carolina Logistics Drive PINEVILLE,NC 28134,USA',31),
(193000000000000246,193000000000100246,'XLX7','XLX7','27','CA','VICTORVILLE','92394','UTC-8','18580 Gateway Dr,Victorville,CA 92394,USA',71),
(193000000000000309,193000000000100309,'XMI3','XMI3','89','','','','','',62),
(193000000000000247,193000000000100247,'XPB2','XPB2','165','FL','APOPKA','32712','UTC-5','XPB2 - 4661 Apopka Logistics Pkwy 32712 - Apopka, FL - United States',34),
(193000000000000248,193000000000100248,'XRI3','XRI3','167','VA','Richmond','23234','UTC-5','4949 Commerce Rd, Richmond, VA 23234',32),
(193000000000000249,193000000000100249,'XUSB','XUSB',NULL,'TX','Fort Worth','76155','UTC-6','14900 Frye Rd.',221),
(193000000000000250,193000000000100250,'XUSC','XUSC',NULL,'PA','Carlisle','17013','UTC-5','40 Logistics Drive',234),
(193000000000000251,193000000000100251,'XUSD','XUSD',NULL,'CA','Stockton','95206','UTC-8','1909 Zephyr St',305),
(193000000000000252,193000000000100252,'XUSE','XUSE',NULL,'IN','Whitestown','46075','UTC-5','5100 S Indianapolis Rd',288),
(193000000000000310,193000000000100310,'洛杉矶仓一件代发','洛杉矶仓一件代发','CADF','CA','RANCHO CUCAMONGA','91730','UTC-8','10767 7TH STREET',60),
(193000000000000311,193000000000100311,'海外仓打托','海外仓打托','13','','','91730','','91730',50),
(193000000000000312,193000000000100312,'私人仓','私人仓','14','','','私人仓','','私人仓',59),
(193000000000000313,193000000000100313,'纽约仓','纽约仓',NULL,'NJ','New Jersey','08817','UTC-5','13、14号门75 Carter Dr Edison,',38),
(193000000000000314,193000000000100314,'纽约仓一件代发','纽约仓一件代发','NJDF','NJ','New Jersey','08817','UTC-5','Door13-14,75 Carter Dr Edison,',37);
DROP PROCEDURE IF EXISTS assert_wh_fba_public_master_data_seed;
DELIMITER //
CREATE PROCEDURE assert_wh_fba_public_master_data_seed()
BEGIN
IF (SELECT COUNT(*) FROM tmp_wh_fba_public_master_data) <> 252 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据源数量不是252';
IF (SELECT COUNT(*) FROM tmp_wh_fba_public_master_data) <> 314 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据源数量不是314';
END IF;
IF EXISTS (
SELECT 1
FROM tmp_wh_fba_public_master_data
WHERE warehouse_code <> UPPER(TRIM(warehouse_code))
OR warehouse_code NOT REGEXP '^[A-Z0-9]{3,5}$'
OR TRIM(city) = ''
OR TRIM(state_province) = ''
OR TRIM(address_line1) = ''
OR TRIM(postal_code) = ''
OR TRIM(timezone) = ''
WHERE TRIM(warehouse_code) = ''
OR warehouse_code <> UPPER(TRIM(warehouse_code))
OR (internal_code IS NOT NULL AND internal_code NOT REGEXP '^[A-Z0-9]{1,16}$')
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据存在未清洗或必填缺失记录';
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据存在空编码或内部编号格式异常';
END IF;
IF EXISTS (
@@ -7051,7 +7148,7 @@ CALL assert_wh_fba_public_master_data_seed();
DROP PROCEDURE IF EXISTS assert_wh_fba_public_master_data_seed;
INSERT INTO wh_warehouse (
id, tenant_id, warehouse_code, warehouse_name, warehouse_type, country_code, timezone,
id, tenant_id, warehouse_code, internal_code, warehouse_name, warehouse_type, country_code, timezone,
contact_name, contact_phone, contact_email, status, remark,
created_by, created_at, updated_by, updated_at, deleted, version
)
@@ -7059,6 +7156,7 @@ SELECT
source.warehouse_id_seed,
0,
source.warehouse_code,
source.internal_code,
source.warehouse_name,
'FBA',
'US',
@@ -7076,6 +7174,7 @@ SELECT
0
FROM tmp_wh_fba_public_master_data source
ON DUPLICATE KEY UPDATE
internal_code = VALUES(internal_code),
warehouse_type = VALUES(warehouse_type),
country_code = VALUES(country_code),
timezone = VALUES(timezone),
@@ -7131,7 +7230,7 @@ BEGIN
WHERE warehouse.tenant_id = 0
AND warehouse.warehouse_type = 'FBA'
AND warehouse.country_code = 'US'
AND warehouse.deleted = 0) <> 252 THEN
AND warehouse.deleted = 0) <> 314 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共仓库写入数量校验失败';
END IF;
@@ -7149,6 +7248,7 @@ BEGIN
AND address.deleted = 0
WHERE warehouse.id IS NULL
OR address.id IS NULL
OR NOT (warehouse.internal_code <=> source.internal_code)
OR address.country_code <> 'US'
OR address.state_province <> source.state_province
OR address.city <> source.city
@@ -12209,6 +12309,8 @@ CALL finance_fee_source_add_column('fin_fee_source', 'review_reason',
"VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因' AFTER review_status");
CALL finance_fee_source_add_column('fin_fee_source', 'operation_version',
"INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用编辑、提交、审核、调整和作废操作版本号' AFTER review_reason");
CALL finance_fee_source_add_column('fin_fee_source', 'wallet_hold_id',
"BIGINT UNSIGNED NULL COMMENT '已由客户资金账户结算时关联的资金冻结记录ID' AFTER operation_version");
CALL finance_fee_source_add_column('fin_fee_source', 'updated_at',
"DATETIME(3) NULL COMMENT '来源状态最后更新时间,数据修改时自动刷新' AFTER created_at");
CALL finance_fee_source_add_column('fin_bill', 'customer_confirmation_status',
@@ -12753,6 +12855,40 @@ CALL queue_table_comment('baiyue_order', 'ord_order_status_history', '订单状
CALL queue_table_comment('baiyue_order', 'ord_outbox_event', '订单事务Outbox表:与订单业务变更在同一本地事务写入,供后台发布器可靠投递RocketMQ,保证数据库状态与领域事件最终一致;不是普通操作日志');
CALL queue_table_comment('baiyue_order', 'sys_audit_outbox', '可靠审计事件本地发送箱');
CALL queue_table_comment('baiyue_warehouse', 'sys_audit_outbox', '可靠审计事件本地发送箱');
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_order_declaration_snapshot (
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
order_id BIGINT UNSIGNED NOT NULL COMMENT 'Order订单ID',
event_id VARCHAR(128) NOT NULL COMMENT 'Order Outbox事件ID',
order_version INT NOT NULL COMMENT 'Order聚合版本,严格单调消费',
request_hash CHAR(64) NOT NULL COMMENT '完整事件SHA-256',
customer_remark VARCHAR(1000) NULL COMMENT '客户备注快照',
expected_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间快照',
channel_code VARCHAR(64) NOT NULL COMMENT '渠道编码快照',
channel_name VARCHAR(128) NOT NULL COMMENT '渠道名称快照',
cargoes_json JSON NOT NULL COMMENT '客户申报货物行完整快照,不覆盖实测事实',
changes_json JSON NULL COMMENT '本版本申报字段变化摘要',
occurred_at DATETIME(3) NOT NULL COMMENT 'Order事件发生时间',
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 (tenant_id, order_id),
UNIQUE KEY uk_order_declaration_event (tenant_id, event_id),
KEY idx_order_declaration_version (tenant_id, order_id, order_version)
) ENGINE=InnoDB COMMENT='Order客户申报版本快照;与箱码、实测、托盘、PDA和计费事实隔离';
CREATE TABLE IF NOT EXISTS fl_order_declaration_event (
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
event_id VARCHAR(128) NOT NULL COMMENT '永久保留的Order Outbox事件ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT 'Order订单ID',
order_version INT NOT NULL COMMENT '事件携带的Order聚合版本',
request_hash CHAR(64) NOT NULL COMMENT '完整事件SHA-256,用于异参重放拒绝',
consumed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事件消费完成时间',
PRIMARY KEY (tenant_id, event_id),
KEY idx_order_declaration_event_order (tenant_id, order_id, order_version)
) ENGINE=InnoDB COMMENT='Order客户申报事件永久幂等账本';
CALL queue_table_comment('baiyue_firstleg', 'fl_order_declaration_event', 'Order客户申报事件永久幂等账本');
CALL queue_table_comment('baiyue_firstleg', 'fl_order_declaration_snapshot', 'Order客户申报版本快照;与箱码、实测、托盘、PDA和计费事实隔离');
CALL queue_table_comment('baiyue_warehouse', 'wh_express_company', '平台公共快递公司主数据,固定归属 tenant_id=0');
@@ -18212,7 +18348,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE lx_order_ddl FROM @ddl; EXECUTE lx_order_ddl; DEALLOCATE PREPARE lx_order_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_inbound_order' AND column_name='lx_code'), 'SELECT 1', 'ALTER TABLE baiyue_firstleg.fl_inbound_order ADD COLUMN lx_code VARCHAR(128) NULL COMMENT ''一件代发领星入库单代码'' AFTER fulfillment_type'));
PREPARE lx_inbound_ddl FROM @ddl; EXECUTE lx_inbound_ddl; DEALLOCATE PREPARE lx_inbound_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_outbound_order' AND column_name='lx_codes'), 'SELECT 1', 'ALTER TABLE baiyue_lastleg.ll_outbound_order ADD COLUMN lx_codes VARCHAR(1000) NULL COMMENT ''关联一件代发领星入库单代码汇总'' AFTER outbound_type'));
SET @ddl = (SELECT IF(
NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name='ll_outbound_order')
OR EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_outbound_order' AND column_name='lx_codes'),
'SELECT 1',
'ALTER TABLE baiyue_lastleg.ll_outbound_order ADD COLUMN lx_codes VARCHAR(1000) NULL COMMENT ''关联一件代发领星入库单代码汇总'' AFTER outbound_type'
));
PREPARE lx_outbound_ddl FROM @ddl; EXECUTE lx_outbound_ddl; DEALLOCATE PREPARE lx_outbound_ddl;
ALTER TABLE baiyue_firstleg.fl_inbound_order
@@ -18431,9 +18572,32 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
KEY idx_terminal_drayage_warning(actual_empty_return_at,estimated_empty_return_at),
CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_terminal_drayage_times CHECK((actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL)
AND (actual_empty_return_at IS NULL OR actual_pickup_at IS NOT NULL)
AND (actual_empty_return_at IS NULL OR actual_empty_return_at >= actual_pickup_at))
AND (actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at))
) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实';
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (
id BIGINT UNSIGNED NOT NULL COMMENT '邮件任务ID',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '归属物理货柜ID',
fleet_supplier_id BIGINT UNSIGNED NOT NULL COMMENT '车队供应商ID快照',
fleet_name VARCHAR(128) NOT NULL COMMENT '车队名称快照',
recipient VARCHAR(254) NOT NULL COMMENT '收件人邮箱',
kind VARCHAR(32) NOT NULL COMMENT '邮件类型:REQUEST_PICKUP提柜请求、CANCEL_PICKUP取消提柜',
subject VARCHAR(255) NOT NULL COMMENT '邮件主题',
body TEXT NOT NULL COMMENT '邮件正文',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING待发送、SENDING发送中、SENT已发送、FAILED失败',
attempts INT NOT NULL DEFAULT 0 COMMENT '发送尝试次数',
failure_reason VARCHAR(500) NULL COMMENT '发送失败原因',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后更新人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
sent_at DATETIME(3) NULL COMMENT '发送成功时间',
PRIMARY KEY(id),
KEY idx_terminal_mail_dispatch(status,id),
KEY idx_terminal_mail_container(container_booking_id,id,status),
CONSTRAINT fk_terminal_mail_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_terminal_mail_kind CHECK(kind IN ('REQUEST_PICKUP','CANCEL_PICKUP')),
CONSTRAINT chk_terminal_mail_status CHECK(status IN ('PENDING','SENDING','SENT','FAILED'))
) ENGINE=InnoDB COMMENT='码头车队邮件任务与投递记录';
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (
id BIGINT UNSIGNED NOT NULL COMMENT '历史ID', drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照', action VARCHAR(32) NOT NULL COMMENT '业务动作',
@@ -18444,7 +18608,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (
KEY idx_terminal_drayage_history(container_booking_id,created_at,id),
CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id),
CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN
('SELECT_FLEET','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN'))
('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN'))
) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史';
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '幂等操作ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID',
@@ -18458,6 +18622,10 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (
-- MySQL DDL 会隐式提交;如果前次执行在末段校验失败,重跑时需把已创建表的列注释收敛到当前基线。
ALTER TABLE fl_terminal_drayage
DROP CHECK chk_terminal_drayage_times,
ADD CONSTRAINT chk_terminal_drayage_times CHECK(
(actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) AND
(actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at)),
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID',
MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID',
MODIFY COLUMN fleet_supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照',
@@ -18475,6 +18643,9 @@ ALTER TABLE fl_terminal_drayage
MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间';
ALTER TABLE fl_terminal_drayage_history
DROP CHECK chk_terminal_drayage_history_action,
ADD CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN
('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')),
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '历史ID',
MODIFY COLUMN drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID',
MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照',
@@ -18502,6 +18673,9 @@ DROP PROCEDURE IF EXISTS assert_terminal_drayage_schema;
DELIMITER $$
CREATE PROCEDURE assert_terminal_drayage_schema()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='baiyue_firstleg' AND TABLE_NAME='fl_terminal_drayage_mail') <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一基线校验失败:码头车队邮件任务表缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='baiyue_firstleg'
AND TABLE_NAME IN ('fl_terminal_drayage','fl_terminal_drayage_history','fl_terminal_drayage_operation')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:目的港码头拖车表不完整';
@@ -18657,9 +18831,42 @@ DELIMITER ;
CALL migrate_container_workspace_permissions();
DROP PROCEDURE IF EXISTS migrate_container_workspace_permissions;
-- 货柜双工作台权限结束,恢复头程数据库上下文
USE baiyue_firstleg;
-- 财务 P0:海外费用原币、计费依据与钱包结算关联
DROP PROCEDURE IF EXISTS add_finance_p0_column;
DELIMITER $$
CREATE PROCEDURE add_finance_p0_column(
IN target_schema VARCHAR(64), IN target_table VARCHAR(64), IN target_column VARCHAR(64), IN column_definition TEXT
)
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=target_schema AND table_name=target_table)
AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=target_schema AND table_name=target_table AND column_name=target_column) THEN
SET @ddl = CONCAT('ALTER TABLE `', target_schema, '`.`', target_table, '` ADD COLUMN `', target_column, '` ', column_definition);
PREPARE statement FROM @ddl;
EXECUTE statement;
DEALLOCATE PREPARE statement;
END IF;
END$$
DELIMITER ;
CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','original_currency',"CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '原始业务币种' AFTER amount");
CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','original_amount',"DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '原币金额' AFTER original_currency");
CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','exchange_rate',"DECIMAL(20,8) NOT NULL DEFAULT 1 COMMENT '原币折算CNY汇率' AFTER original_amount");
CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','exchange_rate_month',"CHAR(7) NOT NULL DEFAULT '1970-01' COMMENT '汇率月份YYYY-MM' AFTER exchange_rate");
CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','chargeable_quantity',"DECIMAL(20,6) NULL COMMENT '计费数量' AFTER exchange_rate_month");
CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','quantity_unit',"VARCHAR(32) NULL COMMENT '计费单位' AFTER chargeable_quantity");
CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','unit_price',"DECIMAL(20,6) NULL COMMENT '原币单价' AFTER quantity_unit");
CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','period_start',"DATE NULL COMMENT '计费期间开始日' AFTER unit_price");
CALL add_finance_p0_column('baiyue_lastleg','ll_finance_fee_source','period_end',"DATE NULL COMMENT '计费期间结束日' AFTER period_start");
CALL add_finance_p0_column('baiyue_finance','fin_fee_source','wallet_hold_id',"BIGINT UNSIGNED NULL COMMENT '已由客户资金账户结算时关联的资金冻结记录ID' AFTER operation_version");
DROP PROCEDURE IF EXISTS add_finance_p0_column;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_finance' AND table_name='fin_fee_source' AND index_name='idx_fin_fee_source_wallet_hold'), 'SELECT 1', 'ALTER TABLE baiyue_finance.fin_fee_source ADD KEY idx_fin_fee_source_wallet_hold(wallet_hold_id)'));
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
SELECT 'PASS' AS migration_status,
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -15,7 +15,7 @@
| 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` |
| 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` |
| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 |
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;`FINANCE_VOUCHER` 按客户与业务 ID 精确校验;`OVERSEAS_UNLOAD_PHOTO` 是多客户共享柜的物理证据,固定平台范围 `tenantId=0` 并按收货会话 ID 精确校验 |
| 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg |
| 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,Lastleg 校验申请/明细关系、客户归属、权限和业务状态 |
| 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 |
@@ -22,7 +22,7 @@ enum BusinessFileCategory {
LOADING_CUSTOMS_DECLARATION(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
CONTAINER_DELIVERY_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_UNLOAD_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_UNLOAD_PHOTO(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
@@ -55,8 +55,8 @@ enum BusinessFileCategory {
}
void validateOwner(long tenantId) {
if (this == OCEAN_BOOKING_DOCUMENT && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "订舱附件必须归属平台");
if ((this == OCEAN_BOOKING_DOCUMENT || this == OVERSEAS_UNLOAD_PHOTO) && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "该附件类型必须归属平台");
}
if (customerOwned && tenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "该附件类型必须归属明确客户");
@@ -33,7 +33,8 @@ class ExportInfrastructureContractTest {
assertTrue(compose.contains("MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX?"));
assertTrue(compose.contains("sh -eu -c 'until sh mqadmin clusterList"));
assertTrue(compose.contains("| grep -q \"BaiyueCluster\""));
assertTrue(compose.contains("restart: on-failure"));
assertTrue(compose.contains("restart: \"on-failure:3\""),
"RocketMQ bootstrap must limit automatic restart retries to three");
String environment=read("..","deploy","env",".env.infra-test.example");
assertTrue(environment.contains("ROCKETMQ_BROKER_IP=rocketmq-broker"));
assertTrue(environment.contains("MQ_TOPIC_PREFIX=test-"));
@@ -44,8 +44,6 @@ class InternalBusinessFileServiceTest {
when(storage.store(eq(0L), anyString(), same(file), any(BusinessFileCategory.class))).thenAnswer(invocation ->
new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf",
"application/pdf", file.getSize(), "b".repeat(64), "customs.pdf"));
when(storage.store(11L, "51", file, BusinessFileCategory.OVERSEAS_UNLOAD_PHOTO)).thenReturn(new BusinessFileStorage.Stored(
"business-files/11/51/unload.jpg", "image/jpeg", file.getSize(), "c".repeat(64), "unload.jpg"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(81L); return 1;
});
@@ -54,7 +52,7 @@ class InternalBusinessFileServiceTest {
var customs = service.upload(0L, "LOADING_CUSTOMS_DECLARATION", "51", 7L, file);
var deliveryOrder = service.upload(0L, "CONTAINER_DELIVERY_ORDER", "41", 7L, file);
var pickupOrder = service.upload(0L, "CONTAINER_PICKUP_ORDER", "41", 7L, file);
var unloadPhoto = service.upload(11L, "OVERSEAS_UNLOAD_PHOTO", "51", 7L, file);
var unloadPhoto = service.upload(0L, "OVERSEAS_UNLOAD_PHOTO", "51", 7L, file);
assertThat(clearance).extracting(InternalBusinessFileService.Metadata::tenantId,
InternalBusinessFileService.Metadata::businessType,
@@ -67,12 +65,14 @@ class InternalBusinessFileServiceTest {
assertThat(unloadPhoto).extracting(InternalBusinessFileService.Metadata::tenantId,
InternalBusinessFileService.Metadata::businessType,
InternalBusinessFileService.Metadata::businessId)
.containsExactly(11L, "OVERSEAS_UNLOAD_PHOTO", "51");
.containsExactly(0L, "OVERSEAS_UNLOAD_PHOTO", "51");
verify(storage).store(0L, "41", file, BusinessFileCategory.CONTAINER_CLEARANCE_DOCUMENT);
verify(storage).store(0L, "51", file, BusinessFileCategory.LOADING_CUSTOMS_DECLARATION);
verify(storage).store(0L, "41", file, BusinessFileCategory.CONTAINER_DELIVERY_ORDER);
verify(storage).store(0L, "41", file, BusinessFileCategory.CONTAINER_PICKUP_ORDER);
verify(storage).store(11L, "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))
.isInstanceOf(BizException.class).hasMessageContaining("必须归属平台");
}
@Test void oceanBookingDocumentUsesPlatformAndBookingOwnership() {
@@ -0,0 +1,65 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.finance.client.FinanceFileClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/api/platform/finance/v1/vouchers")
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
public class PlatformFinanceVoucherController {
private final FinanceFileClient files;
private final String token;
public PlatformFinanceVoucherController(FinanceFileClient files,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.files = files;
this.token = token;
}
public record Voucher(String fileId, String fileName, String contentType, Long sizeBytes) {}
@GetMapping("/{fileId}")
public ApiResponse<Voucher> metadata(@PathVariable long fileId, @RequestParam long customerId) {
var file = scopedFile(customerId, fileId);
return FinanceResponses.success(new Voucher(String.valueOf(fileId), file.fileName(), file.contentType(), file.sizeBytes()));
}
@GetMapping("/{fileId}/content")
public ResponseEntity<byte[]> content(@PathVariable long fileId, @RequestParam long customerId) {
var file = scopedFile(customerId, fileId);
var response = files.download(token, customerId, customerId, fileId);
if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "付款凭证读取失败,请稍后重试");
}
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header("X-Content-Type-Options", "nosniff")
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(file.fileName() == null ? "付款凭证" : file.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.APPLICATION_OCTET_STREAM).body(response.getBody());
}
private FinanceFileClient.Metadata scopedFile(long customerId, long fileId) {
if (customerId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "客户或付款凭证无效");
var response = files.metadata(token, customerId, customerId, fileId);
var file = response == null ? null : response.data();
if (file == null || !Long.valueOf(fileId).equals(file.fileId())
|| !Long.valueOf(customerId).equals(file.tenantId())
|| !"FINANCE_VOUCHER".equals(file.businessType())
|| !String.valueOf(customerId).equals(file.businessId())) {
throw new BizException(ResultCode.BAD_REQUEST, "付款凭证不存在或不属于当前客户");
}
return file;
}
}
@@ -55,8 +55,10 @@ public final class FinancePhase1Dtos {
Long supplierId, String supplierName, Long orderId,
Long inboundId, String inboundCode, Long containerId,
String containerNo, LocalDate businessDate, String feeItemName,
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
String currency, String reviewStatus,
BigDecimal quantity, String quantityUnit, BigDecimal unitPrice,
LocalDate periodStart, LocalDate periodEnd, BigDecimal amount,
String currency, String originalCurrency, BigDecimal originalAmount,
BigDecimal exchangeRate, String exchangeRateMonth, String reviewStatus,
String sourceStatus, boolean statementGenerated) {}
public record FeeSourceDetail(long feeSourceId, String sourceSystem, String sourceType,
@@ -65,8 +67,10 @@ public final class FinancePhase1Dtos {
String customerName, Long supplierId, String supplierName,
Long orderId, Long inboundId, String inboundCode, Long containerId,
String containerNo, LocalDate businessDate, String feeItemName,
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
String currency, String reviewStatus,
BigDecimal quantity, String quantityUnit, BigDecimal unitPrice,
LocalDate periodStart, LocalDate periodEnd, BigDecimal amount,
String currency, String originalCurrency, BigDecimal originalAmount,
BigDecimal exchangeRate, String exchangeRateMonth, String reviewStatus,
String sourceStatus, boolean statementGenerated,
String reviewReason, String sourceHash,
int operationVersion, List<OperationLog> operationLogs) {}
@@ -102,6 +102,7 @@ public class BusinessFeeSourceEventService {
voucherJson(source.paymentVoucherFileIds()), source.sourceHash(), json(source));
long id = jdbc.queryForObject("SELECT id FROM fin_fee_source WHERE source_system=? AND source_type=? AND source_fee_id=? AND source_fee_version=?",
Long.class, system, source.sourceType(), feeId, source.sourceVersion());
linkSettledWalletHold(id, source);
if ("PAYABLE".equals(source.direction()) && "DIRECT_PAID".equals(source.settlementMode()))
materializeDirectPaidCost(system, feeId, source);
return id;
@@ -115,6 +116,13 @@ public class BusinessFeeSourceEventService {
|| blank(value.feeItemName()) || !DIRECTIONS.contains(value.direction())
|| value.businessDate() == null || !"CNY".equals(value.currency())
|| value.totalAmount() == null || value.totalAmount().signum() <= 0
|| !Set.of("CNY", "USD").contains(value.originalCurrency())
|| value.originalAmount() == null || value.originalAmount().signum() <= 0
|| value.exchangeRate() == null || value.exchangeRate().signum() <= 0
|| blank(value.exchangeRateMonth())
|| (value.periodStart() == null) != (value.periodEnd() == null)
|| value.periodStart() != null && value.periodStart().isAfter(value.periodEnd())
|| (value.chargeableQuantity() == null) != blank(value.quantityUnit())
|| !"APPROVED".equals(value.reviewStatus()) || blank(value.sourceHash())) {
throw error("FIN-EVT-012", "跨区费用来源快照字段不完整");
}
@@ -148,7 +156,9 @@ public class BusinessFeeSourceEventService {
value.warehouseId(), value.feeItemCode(), value.feeItemName(), value.direction(), value.supplierId(),
value.supplierName(), value.supplierCategory(), value.settlementMode(), value.paidAt(), value.paymentMethod(),
value.paymentReferenceNo(), value.paymentVoucherFileIds(), value.businessDate(), value.currency(),
value.totalAmount(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null);
value.totalAmount(), value.originalCurrency(), value.originalAmount(), value.exchangeRate(),
value.exchangeRateMonth(), value.chargeableQuantity(), value.quantityUnit(), value.unitPrice(),
value.periodStart(), value.periodEnd(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null);
String expected = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned)));
if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致");
}
@@ -166,15 +176,21 @@ public class BusinessFeeSourceEventService {
}
private void requireSourceNotFrozen(String system, String type, long feeId) {
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system=? AND f.source_type=? AND f.source_fee_id=? AND (EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, system, type, feeId);
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system=? AND f.source_type=? AND f.source_fee_id=? AND (f.wallet_hold_id IS NOT NULL OR EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, system, type, feeId);
if (count != null && count > 0) throw error("FIN-EVT-016", "费用来源已进入有效单据,禁止调整或替代");
}
private void requireNotFrozen(long id) {
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=? AND (EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, id);
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=? AND (f.wallet_hold_id IS NOT NULL OR EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, id);
if (count != null && count > 0) throw error("FIN-EVT-016", "费用来源已进入有效单据,禁止作废");
}
private void linkSettledWalletHold(long feeSourceId, BusinessFeeSourceChangedV1 source) {
if (!"RECEIVABLE".equals(source.direction())) return;
jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE f.id=? AND f.status='AVAILABLE' AND h.status='SETTLED' AND h.amount=f.amount",
feeSourceId);
}
private void materializeDirectPaidCost(String system, long feeId, BusinessFeeSourceChangedV1 value) {
jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,warehouse_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','BUSINESS_DIRECT_PAID',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)",
positive(value.customerId(), "customerId"), "DIRECT-" + system + '-' + value.sourceType() + '-' + feeId + "-V" + value.sourceVersion(),
@@ -152,7 +152,7 @@ public class FinancePhase1OperationService {
String normalizedReview = normalized(reviewStatus,
List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效");
String normalizedSourceStatus = normalized(sourceStatus,
List.of("AVAILABLE", "LOCKED", "BILLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效");
List.of("AVAILABLE", "LOCKED", "BILLED", "SETTLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效");
String generatedSql = "EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)";
String effectiveStatusSql = "CASE WHEN f.status='AVAILABLE' AND " + generatedSql + " THEN 'BILLED' ELSE f.status END";
String businessDateSql = "STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')),'%Y-%m-%d')";
@@ -322,9 +322,15 @@ public class FinancePhase1OperationService {
if (generated && "AVAILABLE".equals(status)) status = "BILLED";
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
BigDecimal amount = rs.getBigDecimal("amount");
BigDecimal unitPrice = quantity != null && quantity.signum() > 0 ? amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null;
BigDecimal originalAmount = firstDecimal(decimal(snapshot, "originalAmount"), amount);
BigDecimal unitPrice = firstDecimal(decimal(snapshot, "unitPrice"),
quantity != null && quantity.signum() > 0 ? originalAmount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null);
return new FeeSourceDetail(id, rs.getString("source_system"), rs.getString("source_type"), firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"), quantity, unitPrice, amount, rs.getString("currency_code"), defaultString(rs.getString("review_status"), "DRAFT"), status, generated,
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"),
quantity, text(snapshot, "quantityUnit"), unitPrice, nullableDate(snapshot, "periodStart"), nullableDate(snapshot, "periodEnd"),
amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount,
firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
defaultString(rs.getString("review_status"), "DRAFT"), status, generated,
rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id));
}
@@ -333,15 +339,18 @@ public class FinancePhase1OperationService {
boolean generated = rs.getBoolean("statement_generated");
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
BigDecimal amount = money(rs, "amount");
BigDecimal unitPrice = quantity != null && quantity.signum() > 0
? amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null;
BigDecimal originalAmount = firstDecimal(decimal(snapshot, "originalAmount"), amount);
BigDecimal unitPrice = firstDecimal(decimal(snapshot, "unitPrice"), quantity != null && quantity.signum() > 0
? originalAmount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null);
return new FeeSourceSummary(rs.getLong("id"), rs.getString("source_system"), rs.getString("source_type"),
firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"),
jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"), nullableLong(rs, "order_id"),
nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"),
text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"),
quantity, unitPrice, amount, rs.getString("currency_code"),
quantity, text(snapshot, "quantityUnit"), unitPrice, nullableDate(snapshot, "periodStart"), nullableDate(snapshot, "periodEnd"),
amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount,
firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
defaultString(rs.getString("review_status"), "DRAFT"),
rs.getString("effective_status"), generated);
}
@@ -421,10 +430,12 @@ public class FinancePhase1OperationService {
}
}
private static Long firstLong(Long first, Long second) { return first != null ? first : second; }
private static BigDecimal firstDecimal(BigDecimal first, BigDecimal second) { return first != null ? first : second; }
private static String firstText(String first, String second) { return StringUtils.hasText(first) ? first : second; }
private static String defaultText(JsonNode node, String field, String fallback) { String value = text(node, field); return value == null || value.isBlank() ? fallback : value; }
private static String defaultString(String value, String fallback) { return value == null || value.isBlank() ? fallback : value; }
private static BigDecimal decimal(JsonNode node, String field) { String value = text(node, field); return value == null || value.isBlank() ? null : new BigDecimal(value); }
private static LocalDate date(JsonNode node, String field) { String value = text(node, field); return value == null || value.isBlank() ? LocalDate.of(1970, 1, 1) : LocalDate.parse(value); }
private static LocalDate nullableDate(JsonNode node, String field) { String value = text(node, field); return value == null || value.isBlank() ? null : LocalDate.parse(value); }
private static FinanceEventException error(String code, String message) { return new FinanceEventException(code, message); }
}
@@ -59,7 +59,7 @@ public class WalletCommandEventService {
}
private WalletHold execute(WalletCommandRequestedV1 command) {
return switch (command.operation()) {
WalletHold hold = switch (command.operation()) {
case "FREEZE" -> walletService.freeze(command.customerId(), command.idempotencyKey(),
new WalletFreezeRequest(command.amount(), command.businessType(), command.businessNo(), command.remark()));
case "SETTLE" -> walletService.settle(command.customerId(), command.businessType(), command.businessNo(),
@@ -68,6 +68,11 @@ public class WalletCommandEventService {
command.idempotencyKey(), new WalletHoldActionRequest(command.remark()));
default -> throw new FinanceEventException("FIN-EVT-001", "资金指令操作无效");
};
if ("SETTLE".equals(command.operation())) {
jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE h.id=? AND f.direction='RECEIVABLE' AND f.status='AVAILABLE' AND f.amount=h.amount",
hold.holdId());
}
return hold;
}
private WalletCommandRequestedV1 validate(EventEnvelope envelope) {
@@ -63,6 +63,8 @@ CALL finance_fee_source_add_column('fin_fee_source', 'review_reason',
"VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因' AFTER review_status");
CALL finance_fee_source_add_column('fin_fee_source', 'operation_version',
"INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用编辑、提交、审核、调整和作废操作版本号' AFTER review_reason");
CALL finance_fee_source_add_column('fin_fee_source', 'wallet_hold_id',
"BIGINT UNSIGNED NULL COMMENT '已由客户资金账户结算时关联的资金冻结记录ID' AFTER operation_version");
CALL finance_fee_source_add_column('fin_fee_source', 'updated_at',
"DATETIME(3) NULL COMMENT '来源状态最后更新时间,数据修改时自动刷新' AFTER created_at");
CALL finance_fee_source_add_column('fin_bill', 'customer_confirmation_status',
+3 -1
View File
@@ -35,13 +35,15 @@ CREATE TABLE IF NOT EXISTS fin_fee_source (
review_status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'Finance费用状态:DRAFT草稿、PENDING待审核、APPROVED已通过、REJECTED已驳回',
review_reason VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因',
operation_version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用编辑、提交、审核、调整和作废操作版本号',
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废',
wallet_hold_id BIGINT UNSIGNED NULL COMMENT '已由客户资金账户结算时关联的资金冻结记录ID',
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SETTLED资金账户已结清、SUPERSEDED已被新版本替代、VOIDED已作废',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '来源快照入池时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '来源状态最后更新时间,数据修改时自动刷新',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_fee_source (source_system, source_type, source_fee_id, source_fee_version),
KEY idx_fin_fee_source_customer (customer_id, status),
KEY idx_fin_fee_source_customer_fund (customer_id, direction, settlement_mode, review_status, status, currency_code, id),
KEY idx_fin_fee_source_wallet_hold (wallet_hold_id),
CONSTRAINT chk_fin_fee_source_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_fee_source_amount CHECK (amount >= 0),
CONSTRAINT chk_fin_fee_source_settlement CHECK (
@@ -61,6 +61,7 @@ class BusinessFeeSourceEventServiceTest {
assertThat(values.getValue()[5]).isEqualTo("PO-US-001");
assertThat(values.getValue()[9]).isNull();
assertThat(values.getValue()[13]).isNull();
verify(jdbc).update(startsWith("UPDATE fin_fee_source f JOIN fin_wallet_hold"), eq(901L));
}
private EventEnvelope eventWithoutInbound() {
@@ -69,7 +70,8 @@ class BusinessFeeSourceEventServiceTest {
"OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null,
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
new BigDecimal("120.00"), "APPROVED", "PUBLISHED", "ACTIVE", null);
new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE,
"2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", null);
String sourceHash = EventPayloadHash.sha256(
EventPayloadCanonicalizer.canonicalize(json.valueToTree(unsigned)));
BusinessFeeSourceChangedV1 payload = new BusinessFeeSourceChangedV1("PUBLISHED", "LASTLEG",
@@ -77,7 +79,8 @@ class BusinessFeeSourceEventServiceTest {
"OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null,
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
new BigDecimal("120.00"), "APPROVED", "PUBLISHED", "ACTIVE", sourceHash);
new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE,
"2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", sourceHash);
JsonNode node = json.valueToTree(payload);
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
return new EventEnvelope("123e4567-e89b-12d3-a456-426614174701",
@@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest {
}
}
assertThat(tableCount).isEqualTo(23);
assertThat(columnCount).isEqualTo(350);
assertThat(columnCount).isEqualTo(351);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@@ -52,7 +52,7 @@ class FinanceSqlCommentContractTest {
void keyIsolationStatusSnapshotAndIdempotencyCommentsUseFrozenMeaning() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
assertThat(sql).contains("tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键'");
assertThat(sql).contains("AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废");
assertThat(sql).contains("AVAILABLE可结算、SETTLED资金账户已结清、SUPERSEDED已被新版本替代、VOIDED已作废");
assertThat(sql).contains("RECEIVED已接收、PROCESSED已完成、FAILED处理失败");
assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照");
assertThat(sql).contains("ON_ACCOUNT账期应付、DIRECT_PAID业务已付");
@@ -0,0 +1,51 @@
package com.baiyue.finance;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.finance.controller.PlatformFinanceVoucherController;
import org.junit.jupiter.api.Test;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class PlatformFinanceVoucherControllerTest {
private final FinanceFileClient files = mock(FinanceFileClient.class);
private final PlatformFinanceVoucherController controller = new PlatformFinanceVoucherController(files, "test-token");
private FinanceFileClient.Metadata file(long customerId, String type, String businessId) {
return new FinanceFileClient.Metadata(91L, customerId, type, businessId, "付款.png", "sha", "image/png", 3L);
}
@Test void returnsFilenameAndDownloadsWithinCustomerScope() {
when(files.metadata("test-token", 11, 11, 91)).thenReturn(ApiResponse.success(file(11, "FINANCE_VOUCHER", "11"), "test", "test"));
when(files.download("test-token", 11, 11, 91)).thenReturn(ResponseEntity.ok(new byte[]{1, 2, 3}));
assertThat(controller.metadata(91, 11).data().fileName()).isEqualTo("付款.png");
var content = controller.content(91, 11);
assertThat(content.getBody()).containsExactly(1, 2, 3);
assertThat(content.getHeaders().getCacheControl()).isEqualTo("no-store");
assertThat(content.getHeaders().getFirst("Content-Disposition")).startsWith("attachment;");
}
@Test void rejectsWrongCustomerBusinessTypeAndBusinessIdBeforeDownload() {
for (var invalid : new FinanceFileClient.Metadata[]{file(12, "FINANCE_VOUCHER", "11"), file(11, "OTHER", "11"), file(11, "FINANCE_VOUCHER", "12")}) {
when(files.metadata("test-token", 11, 11, 91)).thenReturn(ApiResponse.success(invalid, "test", "test"));
assertThatThrownBy(() -> controller.content(91, 11)).isInstanceOf(BizException.class);
}
verify(files, never()).download(anyString(), anyLong(), anyLong(), anyLong());
}
@Test void rejectsMissingMetadataAndFailedContent() {
assertThatThrownBy(() -> controller.content(91, 11)).isInstanceOf(BizException.class);
when(files.metadata("test-token", 11, 11, 91)).thenReturn(ApiResponse.success(file(11, "FINANCE_VOUCHER", "11"), "test", "test"));
when(files.download("test-token", 11, 11, 91)).thenReturn(ResponseEntity.notFound().build());
assertThatThrownBy(() -> controller.content(91, 11)).isInstanceOf(BizException.class);
}
@Test void requiresPlatformListPermission() {
assertThat(PlatformFinanceVoucherController.class.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:finance:customer-arap:list')");
}
}
@@ -71,6 +71,22 @@ class WalletCommandEventServiceTest {
eq("SUCCEEDED"), contains("\"holdStatus\":\"FROZEN\""), eq(envelope.eventId()));
}
@Test
void settledWalletHoldMarksMatchingReceivableSourceSettled() {
EventEnvelope envelope = envelope("SETTLE", 31L, new BigDecimal("88.50"));
when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(envelope.eventId())))
.thenReturn(List.of());
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0);
when(wallet.settle(eq(31L), eq("OVERSEAS_PROCUREMENT_FEE"), eq("PO-1"),
eq("freeze-procurement-1"), any())).thenReturn(new WalletHold(9L, 31L,
"OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("88.50"), new BigDecimal("80.00"),
new BigDecimal("8.50"), "SETTLED", 71L, 72L, null, null, null));
service.consume(envelope);
verify(jdbc).update(startsWith("UPDATE fin_fee_source f JOIN fin_wallet_hold"), eq(9L));
}
@Test
void rejectsCustomerMismatchBeforeTouchingWallet() {
EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("10.00"));
@@ -64,7 +64,8 @@ class FinanceExportProviderServiceTest {
new BigDecimal("100"), "BANK_TRANSFER", "PAY-701", List.of("9001"), 9L, "财务员", LocalDateTime.parse("2026-08-20T10:00:00"), "")), List.of());
FeeSourceDetail source = new FeeSourceDetail(81L, "FIRSTLEG", "CONTAINER_FEE", "CONT-31", 9001L, 2, "RECEIVABLE", 10001L,
"客户A", null, null, null, 21L, "IN-21", 31L, "CONT-31", LocalDate.parse("2026-08-15"), "运费",
BigDecimal.TEN, new BigDecimal("30"), new BigDecimal("300"), "CNY", "APPROVED",
BigDecimal.TEN, "KG", new BigDecimal("30"), null, null, new BigDecimal("300"), "CNY",
"CNY", new BigDecimal("300"), BigDecimal.ONE, "2026-08", "APPROVED",
"AVAILABLE", true, null, "hash", 2, List.of());
when(queryService.statements(10001L, false)).thenReturn(List.of(summary));
when(reconciliationService.detailForPlatform(501L)).thenReturn(detail);
+5 -3
View File
@@ -9,6 +9,8 @@
## 模块入口
散货托盘码:`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java`,契约 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按柜号、FBA 地址内部编号、三位序号生成冻结标签;内部编号从 Warehouse 启用主数据读取,缺失或同柜重复时拒绝生成。重复生成须核对已有标签与明细一致,冲突直接拒绝。支持全部或按仓指定张数输出 100×100 mm PDF;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。
| 目标 | 入口 |
|---|---|
| 内部模块化基线与依赖分析 | `docs/firstleg-internal-modularization-baseline.md`、`scripts/analyze-firstleg-dependencies.mjs`、`scripts/analyze-firstleg-dependencies.test.mjs`、`src/test/resources/architecture/firstleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
@@ -44,8 +46,8 @@
| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` |
| 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
| 海外仓业务国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/海外仓业务/船节点标准化契约.md` |
| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING` 供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` |
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING` 供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 |
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,PDA 完成不推进拖车状态 |
| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` |
@@ -77,7 +79,7 @@
- 国内出口报关在装柜完成后启用,不再阻塞 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才通过离仓报关门禁;完成后以系统身份、确定性幂等键立即尝试确认国内离仓,其他门禁未齐时由受控定时补偿重试,禁止部分入仓成功或重复创建离仓/船司查询事实。
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
- 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。
- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。
- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜完成时间加 72 小时计算,实际报空时间可为空。
- Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。
- `DIRECT_PAID` 仅适用于 `PAYABLE`,必须同时保存 `paidAt`、合法 `paymentMethod`,并至少提供 `paymentReferenceNo` 或一个当前客户范围内的 `FINANCE_VOUCHER`;`RECEIVABLE` 与 `ON_ACCOUNT` 不得残留付款字段。创建、调整和审核前均通过 `FeePaymentSnapshotValidator` 失败关闭复核。
+34
View File
@@ -0,0 +1,34 @@
# 码头车队邮件
## 行为
- 首次选择车队只保存车队;列表“发送邮件”提交当前版本和幂等键,向供应商资料中的单个邮箱发送提柜请求。
- 更换为另一车队时,以该柜、原车队是否存在 `REQUEST_PICKUP` 邮件记录作为“是否已下发”的事实。已下发过时,与车队变更在同一数据库事务中依次生成原车队 `CANCEL_PICKUP`、新车队 `REQUEST_PICKUP` 两个任务,取消邮件使用原请求的收件地址快照;从未下发过时只生成新车队 `REQUEST_PICKUP`,不向原车队发送取消邮件。新车队必须为 ACTIVE/TRUCKING。
- 同一车队重复选择不发邮件、不改变版本。列表“发送邮件”允许人工重复点击,每次使用最新版本都会新增一封提柜请求,便于测试或再次通知;同一个幂等键的请求不会重复入队。
- 同柜严格顺序投递,取消未成功时新请求等待。失败不自动重发,避免 SMTP 超时后重复通知;人工核对未收到后通过邮件记录重试。进程中断超过五分钟的发送任务转为失败待核对。
- `SENT` 表示 SMTP 已接受邮件,不代表车队已阅读或执行。前端入队后只提示待发送,不伪装为成功投递。邮件记录展示最近 100 封,展示收件人、冻结主题和正文、状态及失败信息。
- 邮件发送、重试复用 `platform:firstleg:terminal-drayage:fleet:edit`,记录查看复用列表权限。已提柜、已卸柜或已还柜不再开放新的提柜邮件动作。
## 代码与存储
- `terminaldrayage/service/TerminalDrayageMailTemplates.java`:临时英文取消/提柜模板;正式文案在这里替换。已排队邮件保持原始正文快照。
- `TerminalDrayageMailService.java`:联系资料校验、邮件入队与重试;`TerminalDrayageMailDispatcher.java`:SMTP 投递。
- `terminaldrayage/mapper/TerminalDrayageMapper.java`:邮件存储与状态抢占;`fl_terminal_drayage_mail` 定义于 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 和国内统一 SQL 入口。
- Warehouse `/internal/warehouse/v1/suppliers/{supplierId}/trucking-contact` 仅供内部令牌调用;地址不由浏览器提交。
- 公网接口及动作以 `src/main/resources/openapi/terminal-drayage-v1.yaml`、Warehouse `supplier-v1.yaml` 为准。
## 启用配置
Firstleg 增加 Spring Boot mail starter,仅用于 SMTP,不引入独立邮件平台。默认 `TERMINAL_DRAYAGE_MAIL_ENABLED=false`,可保存任务但不会发信。启用前需配置正式发件账号并核对待发送任务;启用后会投递已排队邮件。
| 环境变量 | 用途 |
|---|---|
| `TERMINAL_DRAYAGE_MAIL_ENABLED` | 默认 false,true 启用投递 |
| `TERMINAL_DRAYAGE_MAIL_HOST` | SMTP 主机 |
| `TERMINAL_DRAYAGE_MAIL_PORT` | 默认 587 |
| `TERMINAL_DRAYAGE_MAIL_USERNAME` | SMTP 登录账号,同时作为邮件发件地址;QQ 邮箱填写完整邮箱地址 |
| `TERMINAL_DRAYAGE_MAIL_PASSWORD` | SMTP 授权码,仅通过环境注入,不使用 QQ 登录密码 |
| `TERMINAL_DRAYAGE_MAIL_SSL` | 默认 false,强制 STARTTLS;true 使用隐式 TLS,一般配合 465 端口 |
| `TERMINAL_DRAYAGE_MAIL_SCAN_DELAY` | 默认 10000 毫秒 |
发布涉及 Admin、Firstleg、Warehouse 和新增邮件表;没有新增 IAM 权限。代码开发不授权部署、执行环境 SQL或向真实车队试发。UI 按现有列表与居中弹框承载,此新增交互依据当前用户需求,历史原型未定义邮件功能;待人工在 1920×1080、100% 缩放下截图确认。
+1
View File
@@ -5,6 +5,7 @@
<parent><groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId><version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath></parent>
<artifactId>firstleg-service</artifactId>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-mail</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-core</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>common-event-contract</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency>
@@ -18,7 +18,7 @@ public interface WarehouseMasterDataClient {
record Detail(Warehouse warehouse) {
}
record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName,
record Warehouse(Long id, Long tenantId, String warehouseCode, String internalCode, String warehouseName,
String warehouseType, String status) {
}
}
@@ -24,4 +24,10 @@ public interface WarehouseSupplierClient {
record Snapshot(long supplierId, String name, String category, String status,
List<String> carrierCodes) {}
@GetMapping("/{supplierId}/trucking-contact")
ApiResponse<TruckingContact> truckingContact(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long supplierId, @RequestParam boolean requireActive);
record TruckingContact(long supplierId, String name, String email) {}
}
@@ -83,7 +83,8 @@ public class PlatformFbaPricingController {
}
@PutMapping("/published")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:adjust')")
@PreAuthorize("hasAnyAuthority('firstleg:fba-pricing:adjust',"
+ " 'firstleg:inbound:confirm-send-fee')")
@BusinessAudit(event = "FBA_FEE_ADJUST", resourceType = "INBOUND", resourceId = "#inboundId",
description = "调整初算或已发布入库费用,不回退待分柜状态")
public ApiResponse<FeeSnapshotResponse> adjustPublished(
@@ -79,7 +79,7 @@ public class PlatformInboundMeasurementController {
@BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "保存入库测量草稿")
@Operation(summary = "保存测量草稿", description = "仅在待测量、测量中或审核驳回状态保存人工测量批次和服务端计算明细;"
@Operation(summary = "保存测量草稿", description = "订单待审核但货物已到仓时可先保存;也可在待测量、测量中或审核驳回状态保存人工测量批次和服务端计算明细;"
+ "进入待审核后批次冻结,确认箱数修正须使用 measurement/items/{itemId}/confirmed-package-count 专用接口。")
@PutMapping("/measurement/draft")
@PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:warehouse-suggestion:edit',"
@@ -96,7 +96,7 @@ public class PlatformInboundMeasurementController {
@BusinessAudit(event = "INBOUND_MEASUREMENT_SUBMIT", resourceType = "INBOUND",
resourceId = "#inboundId", description = "确认测量并提交审核")
@Operation(summary = "确认测量并提交审核",
description = "校验测量图片和全部 OCR 重量后,最多推进到 MEASURE_PENDING。")
description = "校验测量图片和全部 OCR 重量后,最多推进到 MEASURE_PENDING;订单未审核时只保留待审核测量,不能审核通过或进入后续履约。")
@PostMapping("/measurement/submit")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:submit')")
public ApiResponse<InboundMeasurementResponses.MeasurementSubmitted> submit(
@@ -13,8 +13,11 @@ import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.LifecycleResolution;
import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelManifest;
import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelPrintRequest;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingListService;
import com.baiyue.firstleg.service.LoadingPalletLabelService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
@@ -32,6 +35,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.ResponseEntity;
/** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */
@Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询")
@@ -41,6 +45,8 @@ import org.springframework.web.bind.annotation.RestController;
public class PlatformLoadingListController {
@Resource
private LoadingListService service;
@Resource
private LoadingPalletLabelService palletLabels;
@Operation(summary = "分页查询预装清单")
@GetMapping("/loading-plans")
@@ -118,4 +124,26 @@ public class PlatformLoadingListController {
return ApiResponse.success(service.confirmedDetail(planId, warehouseId));
}
@Operation(summary = "生成并查询散货托盘码", description = "仅为散货按真实集装箱号与 FBA 仓稳定生成托盘码;一件代发不参与。")
@PostMapping("/loading-plans/{planId}/pallet-labels/prepare")
@PreAuthorize("hasAnyAuthority('platform:firstleg:preload:list','platform:firstleg:loading-list:list','platform:firstleg:container:list')")
public ApiResponse<PalletLabelManifest> preparePalletLabels(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(palletLabels.prepare(planId, warehouseId));
}
@Operation(summary = "打印散货托盘码", description = "不传仓码与张数时打印全部;按仓打印时返回该仓从第 1 张开始的指定数量,用于补打。")
@PostMapping(value = "/loading-plans/{planId}/pallet-labels/print", produces = "application/pdf")
@PreAuthorize("hasAnyAuthority('platform:firstleg:preload:list','platform:firstleg:loading-list:list','platform:firstleg:container:list')")
public ResponseEntity<byte[]> printPalletLabels(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId,
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
@Valid @RequestBody PalletLabelPrintRequest request) {
FirstlegScopeGuard.platform(user);
return palletLabels.print(planId, warehouseId, request, requestId);
}
}
@@ -125,6 +125,38 @@ public final class LoadingListDtos {
) {
}
public record PalletLabelPrintRequest(
@Size(max = 64) String destinationWarehouseCode,
@Min(1) @Max(999) Integer count
) {
}
public record PalletLabel(
Long labelId, Long itemId, Long accountId, Long inboundId, String inboundCode,
String containerNo, String destinationWarehouseCode, String destinationWarehouseInternalCode,
String destinationWarehouseName,
int palletIndex, String palletCode
) {
}
public record PalletLabelGroup(
String destinationWarehouseCode, String destinationWarehouseInternalCode, String destinationWarehouseName,
int totalCount, List<PalletLabel> labels
) {
public PalletLabelGroup {
labels = labels == null ? List.of() : List.copyOf(labels);
}
}
public record PalletLabelManifest(
Long planId, String planNo, String containerNo, int totalCount,
List<PalletLabelGroup> groups
) {
public PalletLabelManifest {
groups = groups == null ? List.of() : List.copyOf(groups);
}
}
public record PreloadSummary(
Long planId,
Long containerBookingId,
@@ -0,0 +1,71 @@
package com.baiyue.firstleg.mapper;
import lombok.Getter;
import lombok.Setter;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface LoadingPalletLabelMapper {
@Select("""
SELECT p.id AS plan_id,p.plan_no,p.plan_status,b.container_no,b.warehouse_id,
i.id AS item_id,i.account_id,i.inbound_id,i.inbound_code,i.destination_warehouse_id,
i.destination_warehouse_code,i.destination_warehouse_name,
COALESCE(NULLIF(i.pallet_count,0),
CEIL(COALESCE(NULLIF(o.pallet_count,0),
(SELECT COUNT(*) FROM fl_inbound_weight_result weight
WHERE weight.tenant_id=i.account_id AND weight.inbound_id=i.inbound_id
AND weight.deleted=0 AND weight.recognition_status='CONFIRMED'),0)
* i.package_count / NULLIF(o.total_package_count,0)),0) AS pallet_count,
COALESCE(o.fulfillment_type,'STANDARD') AS fulfillment_type,
o.destination_type,i.load_sequence
FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0
LEFT JOIN fl_inbound_order o ON o.id=i.inbound_id AND o.tenant_id=i.account_id AND o.deleted=0
WHERE p.id=#{planId}
ORDER BY i.destination_warehouse_code,i.load_sequence,i.id
FOR UPDATE
""")
List<SourceRow> lockSources(@Param("planId") long planId);
@Select("""
SELECT id AS label_id,loading_plan_id AS plan_id,loading_plan_item_id AS item_id,
account_id,inbound_id,inbound_code,container_no,destination_warehouse_code,
destination_warehouse_name,pallet_index,pallet_code
FROM fl_loading_pallet_label WHERE loading_plan_id=#{planId}
ORDER BY destination_warehouse_code,pallet_index,id
""")
List<LabelRow> labels(@Param("planId") long planId);
@Insert("""
INSERT INTO fl_loading_pallet_label
(id,loading_plan_id,loading_plan_item_id,account_id,inbound_id,inbound_code,
container_no,destination_warehouse_code,destination_warehouse_name,pallet_index,pallet_code,created_at)
VALUES
(#{labelId},#{planId},#{itemId},#{accountId},#{inboundId},#{inboundCode},
#{containerNo},#{destinationWarehouseCode},#{destinationWarehouseName},#{palletIndex},#{palletCode},CURRENT_TIMESTAMP(3))
""")
int insert(LabelRow row);
@Getter @Setter
class SourceRow {
private Long planId; private String planNo; private String planStatus; private String containerNo;
private Long warehouseId; private Long itemId; private Long accountId; private Long inboundId;
private Long destinationWarehouseId;
private String inboundCode; private String destinationWarehouseCode; private String destinationWarehouseName;
private Integer palletCount; private String fulfillmentType; private Integer loadSequence;
private String destinationType;
}
@Getter @Setter
class LabelRow {
private Long labelId; private Long planId; private Long itemId; private Long accountId; private Long inboundId;
private String inboundCode; private String containerNo; private String destinationWarehouseCode;
private String destinationWarehouseName; private Integer palletIndex; private String palletCode;
}
}
@@ -44,9 +44,6 @@ public class OceanBookingPrintService {
private GeneratedPdf generate(long id, String requestId, String template, String suffix) {
Detail detail = bookings.detail(id);
OceanBookingEntity booking = detail.oceanBooking();
if (!"RELEASED".equals(booking.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "订舱放舱后才可打印正式单据");
}
String fileName = booking.getBookingNo() + "-" + suffix + ".pdf";
PrintPdfClient.FreightA4PdfRequest request = new PrintPdfClient.FreightA4PdfRequest(
template, A4, fileName, List.of(new PrintPdfClient.FreightDocument(
@@ -97,7 +94,8 @@ public class OceanBookingPrintService {
fields.put("containerSummary", requirementSummary(detail.containerRequirements()));
fields.put("notes", text(booking.getRemark()));
}
fields.values().removeIf(String::isEmpty);
// Missing booking data is displayed explicitly, including required PDF template fields.
fields.replaceAll((key, value) -> value.isEmpty() ? "—" : value);
return fields;
}
@@ -44,7 +44,7 @@ public class CarrierScheduleNodeOutboxService {
}
ZoneId zone = ZoneId.of(scheduleZone);
var payload = new FirstLegContainerShippingNodeRecordedV1(
string(query.getId()), string(query.getCustomerId()), string(query.getOceanBookingId()),
string(query.getId()), string(query.getOceanBookingId()),
string(query.getContainerBookingId()), normalizedContainerNo,
fact.getStandardNode(), fact.getNodeSequence(),
fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(),
@@ -57,12 +57,12 @@ public class CarrierScheduleNodeOutboxService {
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1,
fact.getActualAt().atZone(zone).toInstant(), RegionCode.CN, RegionCode.OVERSEAS,
string(query.getCustomerId()), "CONTAINER_BOOKING", string(query.getContainerBookingId()),
"0", "CONTAINER_BOOKING", string(query.getContainerBookingId()),
aggregateVersion, aggregateVersion + 1, EventIdentifiers.newUuid(), eventKey, null,
payloadNode, payloadHash).requireValid();
InboundOutboxEntity row = new InboundOutboxEntity();
row.setId(IdWorker.getId()); row.setTenantId(query.getCustomerId());
row.setId(IdWorker.getId()); row.setTenantId(0L);
row.setAggregateType(envelope.aggregateType()); row.setAggregateId(query.getContainerBookingId());
row.setEventType(envelope.eventType()); row.setEventKey(eventKey); row.setEventVersion(1);
row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash);
@@ -34,9 +34,9 @@ import java.util.Set;
public class CartonMeasurementService {
private static final Logger log = LoggerFactory.getLogger(CartonMeasurementService.class);
private static final Set<String> DEVICE_WRITABLE_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
private static final Set<String> MANUAL_COUNT_EDITABLE_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED");
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED");
private static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
private static final long MAX_IMAGE_BYTES = 5L * 1024 * 1024;
@@ -219,6 +219,7 @@ public class FbaPricingService {
/**
* 柜内明细变更后,按最新有效柜内成员重新命中区域重量阶梯。
* 仅使用当前费用快照冻结的选定重量、体积和测量来源,绝不回退到入库单实测值。
* 已发送或客户已确认的费用继续参与柜级计费量聚合,但自身快照保持不变。
*/
@Transactional
public ContainerFeeRecalculationResult recalculateUnlockedContainerCustomerFees(
@@ -257,19 +258,22 @@ public class FbaPricingService {
notReady = true;
continue;
}
if ("SENT".equals(current.getFeeStatus()) || current.getCustomerConfirmedAt() != null) {
throw conflict("柜内存在已发送或客户已确认费用,不能重算阶梯费用");
}
if (!List.of("CALCULATED", "CONFIRMED").contains(current.getFeeStatus())) {
boolean locked = List.of("CONFIRMED", "SENT").contains(current.getFeeStatus())
|| current.getCustomerConfirmedAt() != null;
if (!locked && !"CALCULATED".equals(current.getFeeStatus())) {
throw conflict("柜内费用状态已变化,不能重算阶梯费用");
}
BigDecimal weight = requiredFrozenQuantity(current.getActualWeightKg(), "重量");
BigDecimal volume = requiredFrozenQuantity(current.getActualVolumeCbm(), "体积");
members.add(new ContainerFeeMember(inbound, current,
weight.setScale(3, RoundingMode.HALF_UP), volume.setScale(4, RoundingMode.HALF_UP),
chargeableWeight(weight, volume)));
chargeableWeight(weight, volume), locked));
}
if (notReady) return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_READY, 0);
List<ContainerFeeMember> unlockedMembers = members.stream().filter(member -> !member.locked()).toList();
if (unlockedMembers.isEmpty()) {
return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_APPLICABLE, 0);
}
InboundPricingIdempotencyEntity token = beginContainerRecalculation(
tenantId, containerBookingId, key, normalizedTrigger);
if ("COMPLETED".equals(token.getStatus())) {
@@ -281,7 +285,7 @@ public class FbaPricingService {
.map(member -> new ContainerTierAmountAllocator.Member(member.inbound().getId(), member.chargeableQuantity()))
.toList();
List<ContainerFeeQuote> quotes = new ArrayList<>();
for (ContainerFeeMember member : members) {
for (ContainerFeeMember member : unlockedMembers) {
PricingPolicy policy = pricingPolicy(member.inbound(), now);
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest(
member.inbound().getWarehouseId(), member.inbound().getChannelCode(), member.inbound().getDestinationCode(),
@@ -371,7 +375,8 @@ public class FbaPricingService {
}
private record ContainerFeeMember(InboundPricingEntity inbound, InboundFeeEntity current,
BigDecimal weight, BigDecimal volume, BigDecimal chargeableQuantity) { }
BigDecimal weight, BigDecimal volume, BigDecimal chargeableQuantity,
boolean locked) { }
private record ContainerFeeQuote(ContainerFeeMember member, PricingPolicy policy,
ActivePriceBookEstimator.Estimate estimate) { }
@@ -47,7 +47,8 @@ import java.util.Set;
public class InboundMeasurementOpenApiService {
private static final Logger log = LoggerFactory.getLogger(InboundMeasurementOpenApiService.class);
static final String CALLBACK_PATH = "/openapi/v1/inbound-measurements";
private static final Set<String> ACCEPTABLE_INBOUND_STATUSES = Set.of("WAIT_MEASURE", "MEASURE_REJECTED");
private static final Set<String> ACCEPTABLE_INBOUND_STATUSES = Set.of(
"PREPARING", "WAIT_MEASURE", "MEASURE_REJECTED");
private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000");
private static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
private static final String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH";
@@ -49,9 +49,9 @@ public class InboundMeasurementService {
/** OCR 结果无效或不完整,必须人工录入或修正。 */
private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL";
private static final Set<String> MEASUREMENT_DRAFT_EDITABLE_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
private static final Set<String> WEIGHT_EDITABLE_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
/** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */
private static final String RECOMMENDED_PALLET_TYPE = "STANDARD";
private static final BigDecimal LB_TO_KG = new BigDecimal("0.45359237");
@@ -167,7 +167,7 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SubmitRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
if (!Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED")
if (!Set.of("PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED")
.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "测量已提交或审核完成,不能重复提交或修改冻结结果");
}
@@ -384,7 +384,7 @@ public class InboundMeasurementService {
inbound.setActualGrossWeightKg(totals.grossWeightKg());
inbound.setActualNetWeightKg(totals.netWeightKg());
inbound.setPalletCount(totals.palletCount());
if ("WAIT_MEASURE".equals(inbound.getProcessStatus())) {
if (Set.of("PREPARING", "WAIT_MEASURE").contains(inbound.getProcessStatus())) {
inbound.setProcessStatus("MEASURING");
}
inbound.setUpdatedBy(operatorId);
@@ -40,7 +40,7 @@ public class InboundOrderCreationService {
long inboundId = requireMatchingInboundCode(existing, command.inboundCode());
FulfillmentTaskEntity task = fulfillmentTasks.provision(command, LocalDateTime.now());
linkTask(existing, task.getId());
upgradePreparing(existing, command);
approvePreparedInbound(existing, command);
savePickupSnapshot(existing.getId(), command, LocalDateTime.now());
return inboundId;
}
@@ -132,7 +132,7 @@ public class InboundOrderCreationService {
}
LocalDateTime now = LocalDateTime.now();
InboundOrderEntity inbound = newInbound(command, now, null, "PREPARING", "DRAFT");
InboundOrderEntity inbound = newInbound(command, now, null, "PREPARING", "SUBMITTED");
try {
orders.insert(inbound);
savePickupSnapshot(inbound.getId(), command, now);
@@ -153,6 +153,7 @@ public class InboundOrderCreationService {
.eq(InboundOrderEntity::getTenantId, existing.getTenantId())
.eq(InboundOrderEntity::getId, existing.getId())
.eq(InboundOrderEntity::getProcessStatus, "PREPARING")
.set(InboundOrderEntity::getOrderStatusSnapshot, "SUBMITTED")
.set(InboundOrderEntity::getOrderNo, command.orderNo())
.set(InboundOrderEntity::getCustomerId, command.customerId())
.set(InboundOrderEntity::getCustomerName, command.customerName())
@@ -175,13 +176,16 @@ public class InboundOrderCreationService {
.setSql("version = version + 1"));
}
private void upgradePreparing(InboundOrderEntity existing, OrderApprovedInboundCommand command) {
if (!"PREPARING".equals(existing.getProcessStatus())) return;
private void approvePreparedInbound(InboundOrderEntity existing, OrderApprovedInboundCommand command) {
String currentStatus = existing.getProcessStatus();
if (currentStatus == null || !java.util.Set.of("PREPARING", "MEASURING", "MEASURE_PENDING")
.contains(currentStatus)) return;
String approvedStatus = "PREPARING".equals(currentStatus) ? "WAIT_MEASURE" : currentStatus;
var update = new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, existing.getTenantId())
.eq(InboundOrderEntity::getId, existing.getId())
.eq(InboundOrderEntity::getProcessStatus, "PREPARING")
.set(InboundOrderEntity::getProcessStatus, "WAIT_MEASURE")
.eq(InboundOrderEntity::getProcessStatus, currentStatus)
.set(InboundOrderEntity::getProcessStatus, approvedStatus)
.set(InboundOrderEntity::getOrderStatusSnapshot, "APPROVED")
.set(InboundOrderEntity::getTotalPackageCount, command.totalPackageCount())
.set(InboundOrderEntity::getFulfillmentType, fulfillmentType(command.fulfillmentType()))
@@ -189,12 +193,12 @@ public class InboundOrderCreationService {
.setSql("version = version + 1");
if (orders.update(null, update) != 1) {
InboundOrderEntity current = findByOrder(existing.getTenantId(), existing.getOrderId(), true);
if (current == null || !"WAIT_MEASURE".equals(current.getProcessStatus())) {
if (current == null || !"APPROVED".equals(current.getOrderStatusSnapshot())) {
throw new BizException(ResultCode.CONFLICT, "预入库单审核升级发生并发冲突");
}
return;
}
addHistory(existing.getId(), existing.getTenantId(), "PREPARING", "WAIT_MEASURE",
addHistory(existing.getId(), existing.getTenantId(), currentStatus, approvedStatus,
"ORDER_APPROVED", LocalDateTime.now());
}
@@ -17,6 +17,7 @@ import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
@@ -44,6 +45,8 @@ public class LoadingCompletedOutboxService {
private static final long HANDOVER_VERSION = 2L;
@Resource private LoadingCompletedEventMapper snapshotMapper;
@Resource private LoadingCartonManifestMapper cartonManifestMapper;
@Resource private LoadingPalletLabelMapper palletLabelMapper;
@Resource private LoadingPalletLabelService palletLabelService;
@Resource private InboundOutboxMapper outboxMapper;
@Resource private ObjectMapper objectMapper;
@@ -61,12 +64,15 @@ public class LoadingCompletedOutboxService {
if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件");
validateContainerIdentity(rows);
validateCustomerSnapshots(rows);
palletLabelService.prepare(planId, rows.get(0).getWarehouseId());
Map<Long, List<String>> cartonMarks = cartonLabelPools(rows);
List<PalletRow> palletRows = palletRows(planId);
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels = palletLabelMapper.labels(planId);
return rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId,
java.util.TreeMap::new, java.util.stream.Collectors.toList()))
.entrySet().stream().map(entry -> envelope(entry.getKey(), entry.getValue(),
palletRows.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(), cartonMarks))
palletRows.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(),
standardLabels.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(), cartonMarks))
.toList();
}
@@ -91,13 +97,15 @@ public class LoadingCompletedOutboxService {
}
private EventEnvelope envelope(long accountId, List<Row> rows, List<PalletRow> palletRows,
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels,
Map<Long, List<String>> cartonMarks) {
Row first = rows.get(0);
Map<Long, List<Row>> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy(
Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList()));
List<FirstLegLoadingPlanCompletedV2.Order> orders = byInbound.values().stream()
.map(value -> order(value, palletRows.stream()
.filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList();
.filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(),
standardLabels.stream().filter(item -> Objects.equals(item.getInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList();
var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()),
first.getPlanNo(), string(first.getContainerBookingId()),
ContainerNumbers.requireCanonical(first.getContainerNo()), first.getCabinetNo(),
@@ -114,6 +122,7 @@ public class LoadingCompletedOutboxService {
}
private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows,
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels,
Map<Long, List<String>> cartonMarks) {
List<Row> sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList();
Row first = sorted.get(0);
@@ -129,15 +138,18 @@ public class LoadingCompletedOutboxService {
String fulfillmentType = fulfillmentType(sorted);
String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG";
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> pallets = dropship(fulfillmentType)
? palletSnapshots(sorted, palletRows) : List.of();
? palletSnapshots(sorted, palletRows) : standardLabels.stream()
.map(label -> new FirstLegLoadingPlanCompletedV2.PalletSnapshot(label.getPalletCode(), List.of())).toList();
if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照");
}
Integer frozenPalletCount = dropship(fulfillmentType) || standardLabels.isEmpty()
? actualPalletCount(palletRows) : Integer.valueOf(standardLabels.size());
return new FirstLegLoadingPlanCompletedV2.Order(string(first.getSourceInboundId()),
string(first.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(),
string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()),
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount),
sumNullableInt(sorted, Row::getProductQuantity), actualPalletCount(palletRows),
sumNullableInt(sorted, Row::getProductQuantity), frozenPalletCount,
sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg),
sum(sorted, Row::getVolumeCbm), sum(sorted, Row::getDimensionalWeightKg), items,
string(first.getAccountId()), first.getCustomerName().trim(), orderType, pallets,
@@ -577,7 +577,7 @@ public class LoadingListService {
}
/** 完成 worker 在远程结算前验证最终跨区快照能够完整生成。 */
@Transactional(readOnly = true)
@Transactional
public void requireCompletedEventReady(long planId) {
loadingCompletedOutboxService.requirePublishable(planId);
}
@@ -0,0 +1,191 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.firstleg.client.PrintPdfClient;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabel;
import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelGroup;
import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelManifest;
import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelPrintRequest;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.SourceRow;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
/** 散货托盘码按真实柜号、FBA 地址内部编号和仓内序号组成;一件代发继续使用装箱打托事实。 */
@Service
public class LoadingPalletLabelService {
private static final BigDecimal PAGE_MM = new BigDecimal("100");
@Resource private LoadingPalletLabelMapper mapper;
@Resource private PrintPdfClient printClient;
@Resource private WarehouseMasterDataClient warehouseClient;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@Transactional
public PalletLabelManifest prepare(long planId, long warehouseId) {
List<SourceRow> sources = mapper.lockSources(planId);
if (sources.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在或没有有效明细");
SourceRow first = sources.get(0);
if (!Objects.equals(first.getWarehouseId(), warehouseId)) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不属于当前仓库");
if (!List.of("LOADING", "COMPLETED").contains(first.getPlanStatus())) {
throw new BizException(ResultCode.CONFLICT, "请先确认预装清单,再生成散货托盘码");
}
String containerNo;
try { containerNo = ContainerNumbers.requireCanonical(first.getContainerNo()); }
catch (IllegalArgumentException exception) { throw new BizException(ResultCode.CONFLICT, "请先补录有效集装箱号"); }
List<SourceRow> standard = sources.stream().filter(row -> !"ONE_CLICK_DROPSHIP".equals(row.getFulfillmentType()))
.filter(row -> "FBA".equals(row.getDestinationType()))
.filter(row -> Objects.requireNonNullElse(row.getPalletCount(), 0) > 0).toList();
if (standard.stream().anyMatch(row -> row.getDestinationWarehouseId() == null
|| !StringUtils.hasText(row.getDestinationWarehouseCode()))) {
throw new BizException(ResultCode.CONFLICT, "散货明细缺少 FBA 仓信息,不能生成托盘码");
}
Map<Long, String> internalCodes = resolveInternalCodes(standard);
List<LabelRow> existing = mapper.labels(planId);
List<LabelRow> expected = expected(first, standard, containerNo, internalCodes);
if (existing.isEmpty()) {
expected.forEach(row -> { if (mapper.insert(row) != 1) throw new BizException(ResultCode.CONFLICT, "托盘码生成失败,请刷新后重试"); });
existing = mapper.labels(planId);
}
requireSame(expected, existing);
return manifest(first, existing);
}
@Transactional
public ResponseEntity<byte[]> print(long planId, long warehouseId, PalletLabelPrintRequest request, String requestId) {
PalletLabelManifest manifest = prepare(planId, warehouseId);
String warehouseCode = normalize(request == null ? null : request.destinationWarehouseCode());
Integer count = request == null ? null : request.count();
if ((warehouseCode == null) != (count == null)) throw new BizException(ResultCode.BAD_REQUEST, "按仓打印时必须同时选择 FBA 仓和打印张数");
List<PalletLabel> selected = warehouseCode == null ? manifest.groups().stream().flatMap(group -> group.labels().stream()).toList()
: manifest.groups().stream().filter(group -> group.destinationWarehouseCode().equalsIgnoreCase(warehouseCode))
.flatMap(group -> group.labels().stream()).limit(count).toList();
if (selected.isEmpty() || (count != null && selected.size() != count)) throw new BizException(ResultCode.BAD_REQUEST, "所选 FBA 仓或打印张数无效");
List<PrintPdfClient.Label> labels = selected.stream().map(label -> new PrintPdfClient.Label(
label.palletCode(), label.destinationWarehouseName(), "PALLET " + label.palletIndex(),
"CONTAINER " + label.containerNo(), label.palletCode(), null)).toList();
String suffix = warehouseCode == null ? "全部" : warehouseCode + "-" + count + "张";
var pdfRequest = new PrintPdfClient.InboundMarkPdfRequest("INBOUND_MARK_V1",
new PrintPdfClient.PageSpec(PAGE_MM, PAGE_MM), manifest.containerNo() + "-托盘码-" + suffix + ".pdf", labels);
try { return requirePdf(printClient.generateInboundMarks(internalToken, requestId, pdfRequest)); }
catch (FeignException | NoFallbackAvailableException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试"); }
}
private List<LabelRow> expected(SourceRow plan, List<SourceRow> sources, String containerNo,
Map<Long, String> internalCodes) {
Map<Long, Integer> indexes = new LinkedHashMap<>();
List<LabelRow> result = new ArrayList<>();
for (SourceRow source : sources) {
String warehouseCode = source.getDestinationWarehouseCode().trim().toUpperCase(Locale.ROOT);
String internalCode = internalCodes.get(source.getDestinationWarehouseId());
for (int local = 0; local < source.getPalletCount(); local++) {
int index = indexes.merge(source.getDestinationWarehouseId(), 1, Integer::sum);
if (index > 999) throw new BizException(ResultCode.CONFLICT, "单柜同一 FBA 仓托盘数不能超过 999");
LabelRow row = new LabelRow(); row.setLabelId(IdWorker.getId()); row.setPlanId(plan.getPlanId());
row.setItemId(source.getItemId()); row.setAccountId(source.getAccountId()); row.setInboundId(source.getInboundId());
row.setInboundCode(source.getInboundCode()); row.setContainerNo(containerNo);
row.setDestinationWarehouseCode(warehouseCode); row.setDestinationWarehouseName(source.getDestinationWarehouseName());
row.setPalletIndex(index); row.setPalletCode(containerNo + "-" + internalCode + "-" + String.format("%03d", index));
if (row.getPalletCode().length() > 128) throw new BizException(ResultCode.CONFLICT, "托盘码长度超过系统限制");
result.add(row);
}
}
return result;
}
private Map<Long, String> resolveInternalCodes(List<SourceRow> sources) {
Map<Long, String> codes = new LinkedHashMap<>();
Map<String, Long> owners = new LinkedHashMap<>();
for (SourceRow source : sources) {
long warehouseId = source.getDestinationWarehouseId();
if (codes.containsKey(warehouseId)) continue;
WarehouseMasterDataClient.Warehouse warehouse;
try {
ApiResponse<WarehouseMasterDataClient.Detail> response = warehouseClient.active(internalToken, 0L, warehouseId);
warehouse = response == null || !response.successful() || response.data() == null
? null : response.data().warehouse();
} catch (FeignException | NoFallbackAvailableException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库主数据暂不可用,请稍后重试");
}
if (warehouse == null || !"FBA".equals(warehouse.warehouseType())) {
throw new BizException(ResultCode.CONFLICT, "目的 FBA 仓不存在或未启用");
}
String internalCode = normalize(warehouse.internalCode());
if (internalCode == null) {
throw new BizException(ResultCode.CONFLICT, "FBA 仓“" + warehouse.warehouseCode() + "”未配置内部编号");
}
internalCode = internalCode.toUpperCase(Locale.ROOT);
Long owner = owners.putIfAbsent(internalCode, warehouseId);
if (owner != null && owner != warehouseId) {
throw new BizException(ResultCode.CONFLICT, "同柜 FBA 仓内部编号重复,请先修正仓库主数据");
}
codes.put(warehouseId, internalCode);
}
return codes;
}
private void requireSame(List<LabelRow> expected, List<LabelRow> actual) {
List<String> left = expected.stream().map(this::identity).toList();
List<String> right = actual.stream().map(this::identity).toList();
if (!left.equals(right)) throw new BizException(ResultCode.CONFLICT, "已生成的托盘码与当前装柜明细不一致");
}
private String identity(LabelRow row) { return row.getItemId()+"|"+row.getPalletCode(); }
private PalletLabelManifest manifest(SourceRow plan, List<LabelRow> rows) {
Map<String, List<LabelRow>> grouped = rows.stream().collect(java.util.stream.Collectors.groupingBy(
LabelRow::getDestinationWarehouseCode, LinkedHashMap::new, java.util.stream.Collectors.toList()));
List<PalletLabelGroup> groups = grouped.values().stream().map(values -> new PalletLabelGroup(
values.get(0).getDestinationWarehouseCode(), internalCode(values.get(0)),
values.get(0).getDestinationWarehouseName(), values.size(),
values.stream().map(this::label).toList())).toList();
String containerNo = rows.isEmpty() ? plan.getContainerNo() : rows.get(0).getContainerNo();
return new PalletLabelManifest(plan.getPlanId(), plan.getPlanNo(), containerNo, rows.size(), groups);
}
private PalletLabel label(LabelRow row) { return new PalletLabel(row.getLabelId(), row.getItemId(), row.getAccountId(), row.getInboundId(), row.getInboundCode(), row.getContainerNo(), row.getDestinationWarehouseCode(), internalCode(row), row.getDestinationWarehouseName(), row.getPalletIndex(), row.getPalletCode()); }
private String internalCode(LabelRow row) {
String prefix = row.getContainerNo() + "-";
String suffix = "-" + String.format("%03d", row.getPalletIndex());
String palletCode = row.getPalletCode();
return palletCode.substring(prefix.length(), palletCode.length() - suffix.length());
}
private static String normalize(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private ResponseEntity<byte[]> requirePdf(ResponseEntity<byte[]> response) {
byte[] body = response == null || response.getBody() == null ? new byte[0] : response.getBody();
MediaType type = response == null ? null : response.getHeaders().getContentType();
if (type == null || !MediaType.APPLICATION_PDF.isCompatibleWith(type) || body.length == 0) {
try { ApiResponse<Object> error = objectMapper.readValue(body, new TypeReference<>() { });
if (error != null && !error.successful()) throw new BizException(ResultCode.of(error.code()), error.message());
} catch (BizException exception) { throw exception; } catch (Exception ignored) { }
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务返回了无效响应");
}
HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_PDF);
String disposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
if (StringUtils.hasText(disposition)) headers.set(HttpHeaders.CONTENT_DISPOSITION, disposition);
headers.setContentLength(body.length); return ResponseEntity.ok().headers(headers).body(body);
}
}
@@ -43,7 +43,7 @@ public class MeasurementBatchService {
/** 已接收第三方测量数据,待平台提交审核。 */
static final String STATUS_RECEIVED = "RECEIVED";
private static final Set<String> EDITABLE_INBOUND_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
private static final BigDecimal MAX_RAW_VOLUME_CBM = new BigDecimal("999999999999.999999");
private static final BigDecimal MAX_TOTAL_VOLUME_CBM = new BigDecimal("99999999999999.9999");
@@ -64,6 +64,9 @@ public class MeasurementReviewService {
InboundOrderEntity order = requireOrder(tenantId, inboundId);
requirePendingReview(order);
if (!"APPROVED".equals(order.getOrderStatusSnapshot())) {
throw new BizException(ResultCode.CONFLICT, "订单尚未审核通过,已到仓测量数据可保留但暂不能审核");
}
InboundMeasurementEntity measurement = repository.findMeasurement(
tenantId, inboundId, request.measurementId());
if (measurement == null) {
@@ -9,6 +9,10 @@ import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.FleetSelectio
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.PickupAppointmentRequest;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest;
import com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageMailService;
import com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageMailService.Mailbox;
import jakarta.annotation.Resource;
import com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -36,6 +40,33 @@ import java.util.List;
@Tag(name = "目的港码头拖车")
@RequestMapping("/api/platform/v1/terminal-drayages")
public class PlatformTerminalDrayageController {
@Resource private TerminalDrayageMailService mails;
@PostMapping("/{containerBookingId}/pickup-email")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:fleet:edit')")
public ApiResponse<View> sendPickupEmail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody EmailRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.authorize(service.sendPickupEmail(containerBookingId, user.userId(), user.username(), key, request), user.permissions()));
}
@GetMapping("/{containerBookingId}/emails")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
public ApiResponse<Mailbox> emails(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long containerBookingId) {
FirstlegScopeGuard.platform(user); service.detail(containerBookingId);
return ApiResponse.success(mails.list(containerBookingId));
}
@PostMapping("/{containerBookingId}/emails/{mailId}/retry")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:fleet:edit')")
public ApiResponse<Void> retryEmail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId, @PathVariable @Positive long mailId) {
FirstlegScopeGuard.platform(user); service.detail(containerBookingId);
mails.retry(containerBookingId, mailId, user.userId());
return ApiResponse.success(null);
}
private final TerminalDrayageService service;
@GetMapping
@@ -11,6 +11,7 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerAc
public final class TerminalDrayageDtos {
private TerminalDrayageDtos() {}
public record EmailRequest(@NotNull @Min(0) Integer expectedVersion) {}
public record FleetSelectionRequest(@NotNull @Min(0) Integer expectedVersion,
@NotNull @Positive Long fleetSupplierId,
@@ -110,4 +110,65 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
record Context(Long containerBookingId, String containerNo, Long oceanBookingId, String recordStatus,
LocalDateTime unloadingCompletedAt) {}
record Operation(String operation, String requestHash, Integer resultVersion) {}
record MailTask(Long id, Long containerBookingId, Long fleetSupplierId, String fleetName,
String recipient, String kind, String subject, String body, String status,
Integer attempts, String failureReason, LocalDateTime createdAt, LocalDateTime sentAt) {}
@Insert("""
INSERT INTO fl_terminal_drayage_mail(id,container_booking_id,fleet_supplier_id,fleet_name,
recipient,kind,subject,body,status,created_by,updated_by)
VALUES(#{id},#{containerId},#{supplierId},#{name},#{email},#{kind},#{subject},#{body},'PENDING',#{operatorId},#{operatorId})
""")
int insertMail(@Param("id") long id, @Param("containerId") long containerId,
@Param("supplierId") long supplierId, @Param("name") String name, @Param("email") String email,
@Param("kind") String kind, @Param("subject") String subject, @Param("body") String body,
@Param("operatorId") long operatorId);
@Select("SELECT id,container_booking_id AS containerBookingId,fleet_supplier_id AS fleetSupplierId,fleet_name AS fleetName,recipient,kind,subject,body,status,attempts,failure_reason AS failureReason,created_at AS createdAt,sent_at AS sentAt FROM fl_terminal_drayage_mail WHERE container_booking_id=#{containerId} ORDER BY id DESC LIMIT 100")
List<MailTask> mails(@Param("containerId") long containerId);
@Select("""
SELECT id,container_booking_id AS containerBookingId,fleet_supplier_id AS fleetSupplierId,fleet_name AS fleetName,recipient,kind,subject,body,status,attempts,failure_reason AS failureReason,created_at AS createdAt,sent_at AS sentAt FROM fl_terminal_drayage_mail WHERE container_booking_id=#{containerId}
AND fleet_supplier_id=#{supplierId} AND kind='REQUEST_PICKUP' ORDER BY id DESC LIMIT 1
""")
MailTask lastPickupMail(@Param("containerId") long containerId, @Param("supplierId") long supplierId);
@Select("""
SELECT m.id,m.container_booking_id AS containerBookingId,m.fleet_supplier_id AS fleetSupplierId,m.fleet_name AS fleetName,m.recipient,m.kind,m.subject,m.body,m.status,m.attempts,m.failure_reason AS failureReason,m.created_at AS createdAt,m.sent_at AS sentAt FROM fl_terminal_drayage_mail m WHERE m.status='PENDING'
AND NOT EXISTS(SELECT 1 FROM fl_terminal_drayage_mail earlier
WHERE earlier.container_booking_id=m.container_booking_id AND earlier.id<m.id AND earlier.status<>'SENT')
ORDER BY m.id LIMIT 20
""")
List<MailTask> pendingMails();
@Update("""
UPDATE fl_terminal_drayage_mail SET status='SENDING',attempts=attempts+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND status='PENDING'
""")
int claimMail(@Param("id") long id);
@Update("""
UPDATE fl_terminal_drayage_mail SET status='SENT',sent_at=CURRENT_TIMESTAMP(3),failure_reason=NULL,
updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status='SENDING'
""")
int completeMail(@Param("id") long id);
@Update("""
UPDATE fl_terminal_drayage_mail SET status='FAILED',failure_reason=#{reason},updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND status='SENDING'
""")
int failMail(@Param("id") long id, @Param("reason") String reason);
@Update("""
UPDATE fl_terminal_drayage_mail SET status='FAILED',failure_reason='发送中断,结果待核对;确认未收到后再重试',
updated_at=CURRENT_TIMESTAMP(3) WHERE status='SENDING' AND updated_at<DATE_SUB(CURRENT_TIMESTAMP(3), INTERVAL 5 MINUTE)
""")
int expireMails();
@Update("""
UPDATE fl_terminal_drayage_mail SET status='PENDING',failure_reason=NULL,updated_by=#{operatorId},updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND container_booking_id=#{containerId} AND status='FAILED'
""")
int retryMail(@Param("containerId") long containerId, @Param("id") long id, @Param("operatorId") long operatorId);
}
@@ -0,0 +1,60 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name="baiyue.terminal-drayage.mail.enabled", havingValue="true")
public class TerminalDrayageMailDispatcher {
private final TerminalDrayageMapper mapper;
@Value("${baiyue.terminal-drayage.mail.host}") private String host;
@Value("${baiyue.terminal-drayage.mail.port:587}") private int port;
@Value("${baiyue.terminal-drayage.mail.username}") private String username;
@Value("${baiyue.terminal-drayage.mail.password}") private String password;
@Value("${baiyue.terminal-drayage.mail.ssl:false}") private boolean ssl;
JavaMailSenderImpl sender() {
TerminalDrayageMailService.validateAddress(username);
var sender = new JavaMailSenderImpl();
sender.setHost(host); sender.setPort(port); sender.setUsername(username); sender.setPassword(password);
sender.setDefaultEncoding("UTF-8");
var properties = sender.getJavaMailProperties();
properties.setProperty("mail.smtp.auth", "true");
properties.setProperty("mail.smtp.ssl.enable", String.valueOf(ssl));
properties.setProperty("mail.smtp.starttls.enable", String.valueOf(!ssl));
properties.setProperty("mail.smtp.starttls.required", String.valueOf(!ssl));
properties.setProperty("mail.smtp.ssl.checkserveridentity", "true");
properties.setProperty("mail.smtp.connectiontimeout", "10000");
properties.setProperty("mail.smtp.timeout", "10000");
properties.setProperty("mail.smtp.writetimeout", "10000");
return sender;
}
@Scheduled(fixedDelayString="${baiyue.terminal-drayage.mail.scan-delay:10000}")
public void dispatch() {
mapper.expireMails();
for (var mail : mapper.pendingMails()) {
if (mapper.claimMail(mail.id()) != 1) continue;
try {
var message = new SimpleMailMessage();
message.setFrom(username); message.setTo(mail.recipient());
message.setSubject(mail.subject()); message.setText(mail.body());
sender().send(message);
if (mapper.completeMail(mail.id()) != 1) log.warn("Terminal drayage mail completion requires review: {}", mail.id());
} catch (Exception exception) {
// SMTP 超时不等于未送达,禁止自动重发;失败阻断同柜后续邮件,交由人工核对。
mapper.failMail(mail.id(), "发送失败或结果不确定,请核对收件情况及发信配置后重试");
log.warn("Terminal drayage mail requires review: {}, {}", mail.id(), exception.getClass().getSimpleName());
}
}
}
}
@@ -0,0 +1,89 @@
package com.baiyue.firstleg.terminaldrayage.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.client.WarehouseSupplierClient;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.MailTask;
import feign.FeignException;
import jakarta.mail.internet.InternetAddress;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.List;
@Service
@RequiredArgsConstructor
public class TerminalDrayageMailService {
private final TerminalDrayageMapper mapper;
private final WarehouseSupplierClient suppliers;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@Value("${baiyue.terminal-drayage.mail.enabled:false}") private boolean enabled;
public record Mailbox(boolean deliveryEnabled, List<MailTask> records) {}
public Mailbox list(long containerBookingId) { return new Mailbox(enabled, mapper.mails(containerBookingId)); }
public void request(long containerId, String containerNo, long supplierId, long operatorId) {
var contact = contact(supplierId, true);
enqueue(containerId, containerNo, supplierId, contact.name(), contact.email(), "REQUEST_PICKUP", operatorId);
}
public void replaceFleet(long containerId, String containerNo, long oldSupplierId, long newSupplierId, long operatorId) {
var next = contact(newSupplierId, true);
// 请求邮件记录就是当前车队是否已下发的事实;未下发过时不能向原车队发送无来源的取消通知。
MailTask previous = mapper.lastPickupMail(containerId, oldSupplierId);
if (previous != null) {
enqueue(containerId, containerNo, oldSupplierId, previous.fleetName(), previous.recipient(),
"CANCEL_PICKUP", operatorId);
}
enqueue(containerId, containerNo, newSupplierId, next.name(), next.email(), "REQUEST_PICKUP", operatorId);
}
private WarehouseSupplierClient.TruckingContact contact(long supplierId, boolean requireActive) {
try {
var response = suppliers.truckingContact(internalToken, supplierId, requireActive);
if (response == null || !response.successful() || response.data() == null
|| response.data().supplierId() != supplierId) {
throw new BizException(ResultCode.CONFLICT, "车队联系资料不可用,请检查供应商资料");
}
var value = response.data();
validateAddress(value.email());
if (!StringUtils.hasText(value.name())) throw new BizException(ResultCode.CONFLICT, "车队名称缺失");
return value;
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "车队联系资料服务暂不可用");
}
}
static void validateAddress(String email) {
try {
if (!StringUtils.hasText(email) || email.length() > 254 || email.contains("\r") || email.contains("\n")) throw new IllegalArgumentException();
InternetAddress[] parsed = InternetAddress.parse(email.trim(), true);
if (parsed.length != 1 || parsed[0].isGroup() || !email.trim().equals(parsed[0].getAddress())) throw new IllegalArgumentException();
parsed[0].validate();
if (!parsed[0].getAddress().contains("@")) throw new IllegalArgumentException();
} catch (Exception exception) {
throw new BizException(ResultCode.BAD_REQUEST, "车队未配置有效邮箱,请先在供应商资料中维护单个收件邮箱");
}
}
private void enqueue(long containerId, String containerNo, long supplierId, String fleetName,
String email, String kind, long operatorId) {
if (!StringUtils.hasText(containerNo)) throw new BizException(ResultCode.CONFLICT, "请先补齐真实集装箱号再发送邮件");
validateAddress(email);
var content = TerminalDrayageMailTemplates.render(kind, containerNo, fleetName);
mapper.insertMail(IdWorker.getId(), containerId, supplierId, fleetName, email.trim(), kind,
content.subject(), content.body(), operatorId);
}
@Transactional
public void retry(long containerId, long mailId, long operatorId) {
if (mapper.retryMail(containerId, mailId, operatorId) != 1) {
throw new BizException(ResultCode.CONFLICT, "只有发送失败的邮件可以重试,请刷新邮件记录");
}
}
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.terminaldrayage.service;
/** 临时英文模板。正式邮件文案到位后只替换此处,不改变任务与发送流程。 */
public final class TerminalDrayageMailTemplates {
private TerminalDrayageMailTemplates() {}
public record Content(String subject, String body) {}
public static Content render(String kind, String containerNo, String fleetName) {
boolean cancel = "CANCEL_PICKUP".equals(kind);
String subject = (cancel ? "Cancel port pickup - " : "Port pickup request - ") + containerNo;
String body = "Dear " + fleetName + ",\n\n"
+ (cancel ? "Please cancel our port pickup request for container "
: "Please arrange port pickup for container ")
+ containerNo + ".\n"
+ (cancel ? "Please do not dispatch a truck for this request.\n"
: "Please contact our operations team to confirm the pickup details before dispatch.\n")
+ "\nPlease reply to confirm receipt.\nThank you.\nOperations Team";
return new Content(subject, body);
}
}
@@ -20,6 +20,7 @@ import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Operation;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -46,6 +47,7 @@ public class TerminalDrayageService {
private final WarehouseSupplierSnapshotService suppliers;
private final CarrierScheduleQueryService carrierSchedules;
private final ObjectMapper objectMapper;
private final TerminalDrayageMailService mails;
private Clock clock = Clock.systemDefaultZone();
public View detail(long containerBookingId) {
@@ -95,6 +97,10 @@ public class TerminalDrayageService {
requireVersion(value, request.expectedVersion());
if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可更换码头车队");
var fleet = suppliers.trucking(request.fleetSupplierId(), request.fleetSupplierName());
if (value != null && Objects.equals(value.getFleetSupplierId(), fleet.supplierId())) return view(context, value);
if (value != null && value.getFleetSupplierId() != null) {
mails.replaceFleet(containerBookingId, context.base().containerNo(), value.getFleetSupplierId(), fleet.supplierId(), operatorId);
}
int fromVersion = version(value);
String fromStatus = status(context, value);
if (value == null) value = create(containerBookingId, operatorId, operatorName);
@@ -197,6 +203,23 @@ public class TerminalDrayageService {
actualAt, null, null, StringUtils.hasText(reason) ? reason.trim() : "码头拖车操作同步"));
}
@Transactional
public View sendPickupEmail(long containerBookingId, long operatorId, String operatorName, String key,
EmailRequest request) {
String action = "SEND_PICKUP_EMAIL";
WorkContext context = requiredLockedContext(containerBookingId);
View replay = replay(context, action, key, request);
if (replay != null) return replay;
TerminalDrayageEntity value = requiredAggregate(containerBookingId);
requireVersion(value, request.expectedVersion());
if (!allowedActions(context, value).contains(action)) throw conflict("当前货柜不能发送提柜请求邮件");
int fromVersion = version(value);
String fromStatus = status(context, value);
mails.request(containerBookingId, context.base().containerNo(), value.getFleetSupplierId(), operatorId);
persist(value, fromVersion, operatorId, operatorName);
return finish(context, value, action, key, request, fromStatus, fromVersion, "提柜邮件已加入发送队列", operatorId, operatorName);
}
private View finish(WorkContext context, TerminalDrayageEntity value, String action, String key, Object request,
String fromStatus, int fromVersion, String reason,
long operatorId, String operatorName) {
@@ -267,6 +290,8 @@ public class TerminalDrayageService {
private List<String> allowedActions(WorkContext context, TerminalDrayageEntity value) {
List<String> result = new ArrayList<>();
if (value != null && value.getFleetSupplierId() != null && actualPickupAt(context, value) == null
&& actualEmptyReturnAt(context, value) == null && context.base().unloadingCompletedAt() == null) result.add("SEND_PICKUP_EMAIL");
CarrierScheduleNode current = node(context.facts().latestActualNode());
if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
&& current.sequence() < CarrierScheduleNode.PICKED_UP.sequence()
@@ -371,6 +396,7 @@ public class TerminalDrayageService {
private static String permission(String action) {
return switch (action) {
case "SELECT_FLEET" -> "platform:firstleg:terminal-drayage:fleet:edit";
case "SEND_PICKUP_EMAIL" -> "platform:firstleg:terminal-drayage:fleet:edit";
case "SCHEDULE_PICKUP" -> "platform:firstleg:terminal-drayage:pickup-appointment:edit";
case "RECORD_PICKUP" -> "platform:firstleg:terminal-drayage:pickup:confirm";
case "RECORD_EMPTY_RETURN" -> "platform:firstleg:terminal-drayage:empty-return:confirm";
@@ -30,6 +30,15 @@ spring:
size: ${FIRSTLEG_SCHEDULING_POOL_SIZE:5}
thread-name-prefix: firstleg-scheduler-
baiyue:
terminal-drayage:
mail:
enabled: ${TERMINAL_DRAYAGE_MAIL_ENABLED:false}
host: ${TERMINAL_DRAYAGE_MAIL_HOST:}
port: ${TERMINAL_DRAYAGE_MAIL_PORT:587}
username: ${TERMINAL_DRAYAGE_MAIL_USERNAME:}
password: ${TERMINAL_DRAYAGE_MAIL_PASSWORD:}
ssl: ${TERMINAL_DRAYAGE_MAIL_SSL:false}
scan-delay: ${TERMINAL_DRAYAGE_MAIL_SCAN_DELAY:10000}
carrier-schedule:
providers:
matson-automated: ${MATSON_TRACKING_ENABLED:false}
@@ -354,6 +354,29 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1))
) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层';
CREATE TABLE IF NOT EXISTS fl_loading_pallet_label (
id BIGINT UNSIGNED NOT NULL COMMENT '散货托盘标签ID(雪花ID)',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID',
loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '标签归属装柜明细ID',
account_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID快照',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入仓单ID快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
container_no VARCHAR(64) NOT NULL COMMENT '真实集装箱号快照',
destination_warehouse_code VARCHAR(64) NOT NULL COMMENT 'FBA仓编码快照',
destination_warehouse_name VARCHAR(128) NULL COMMENT 'FBA仓名称快照',
pallet_index INT UNSIGNED NOT NULL COMMENT '当前柜当前FBA仓内从1开始的序号',
pallet_code VARCHAR(128) NOT NULL COMMENT '托盘码:集装箱号-FBA仓编码-三位序号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '生成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pallet_code (pallet_code),
UNIQUE KEY uk_loading_pallet_index (loading_plan_id,destination_warehouse_code,pallet_index),
KEY idx_loading_pallet_item (loading_plan_item_id,pallet_index),
KEY idx_loading_pallet_inbound (account_id,inbound_id),
CONSTRAINT fk_loading_pallet_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan(id),
CONSTRAINT fk_loading_pallet_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item(id),
CONSTRAINT chk_loading_pallet_index CHECK (pallet_index BETWEEN 1 AND 999)
) ENGINE=InnoDB COMMENT='散货按柜号和FBA仓冻结的可打印托盘码';
-- 柜次级简单文件仅承载目的港清关和提货单;PU资料作为数字码保存在货柜主表。
CREATE TABLE IF NOT EXISTS fl_container_document (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
@@ -23,10 +23,33 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_terminal_drayage_times CHECK(
(actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) AND
(actual_empty_return_at IS NULL OR actual_pickup_at IS NOT NULL) AND
(actual_empty_return_at IS NULL OR actual_empty_return_at >= actual_pickup_at))
(actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at))
) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实';
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (
id BIGINT UNSIGNED NOT NULL COMMENT '邮件任务ID',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '归属物理货柜ID',
fleet_supplier_id BIGINT UNSIGNED NOT NULL COMMENT '车队供应商ID快照',
fleet_name VARCHAR(128) NOT NULL COMMENT '车队名称快照',
recipient VARCHAR(254) NOT NULL COMMENT '收件人邮箱',
kind VARCHAR(32) NOT NULL COMMENT '邮件类型:REQUEST_PICKUP提柜请求、CANCEL_PICKUP取消提柜',
subject VARCHAR(255) NOT NULL COMMENT '邮件主题',
body TEXT NOT NULL COMMENT '邮件正文',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING待发送、SENDING发送中、SENT已发送、FAILED失败',
attempts INT NOT NULL DEFAULT 0 COMMENT '发送尝试次数',
failure_reason VARCHAR(500) NULL COMMENT '发送失败原因',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后更新人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
sent_at DATETIME(3) NULL COMMENT '发送成功时间',
PRIMARY KEY(id),
KEY idx_terminal_mail_dispatch(status,id),
KEY idx_terminal_mail_container(container_booking_id,id,status),
CONSTRAINT fk_terminal_mail_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_terminal_mail_kind CHECK(kind IN ('REQUEST_PICKUP','CANCEL_PICKUP')),
CONSTRAINT chk_terminal_mail_status CHECK(status IN ('PENDING','SENDING','SENT','FAILED'))
) ENGINE=InnoDB COMMENT='码头车队邮件任务与投递记录';
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (
id BIGINT UNSIGNED NOT NULL COMMENT '历史ID',
drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID',
@@ -44,7 +67,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (
KEY idx_terminal_drayage_history(container_booking_id,created_at,id),
CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id),
CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN
('SELECT_FLEET','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN'))
('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN'))
) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史';
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (
@@ -950,8 +950,8 @@ paths:
summary: 保存测量草稿
description: >-
原单据号码、申报毛重和申报体积由服务端从客户订单生成快照,客户端不得提交覆盖;
actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。仅允许 WAIT_MEASURE、MEASURING
或 MEASURE_REJECTED 状态保存;进入 MEASURE_PENDING 后整批测量冻结,确认箱数修正使用
actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。仅允许 PREPARING(订单待审核但已到仓)、
WAIT_MEASURE、MEASURING 或 MEASURE_REJECTED 状态保存;进入 MEASURE_PENDING 后整批测量冻结,确认箱数修正使用
measurement/items/{itemId}/confirmed-package-count 明细专用接口。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -1154,7 +1154,7 @@ paths:
/api/platform/v1/inbounds/{inboundId}/measurements/approve:
post:
summary: 审核通过测量
description: 需要 firstleg:inbound:measurement-review 权限。标准头程审核通过后进入 WAIT_SPLIT;一件代发进入 WAIT_PALLETIZE,完成逐箱清点和封托后再进入 WAIT_SPLIT。审核时按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示。
description: 需要 firstleg:inbound:measurement-review 权限,且 Order 权威订单必须已审核通过。订单待审核期间可保留并提交到仓测量数据,但不能审核测量。标准头程审核通过后进入 WAIT_SPLIT;一件代发进入 WAIT_PALLETIZE,完成逐箱清点和封托后再进入 WAIT_SPLIT。审核时按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和后续履约状态。
parameters:
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -1175,7 +1175,7 @@ paths:
/api/platform/v1/inbounds/{inboundId}/measurements/submit:
post:
summary: 提交测量完成
description: 提交只校验并复用称重图片确认阶段已经 OCCUPIED 且绑定完整的权威整票库位块,然后进入客服待审核;不会首次分配、重建或更换已显示槽位。
description: 提交只校验并复用称重图片确认阶段已经 OCCUPIED 且绑定完整的权威整票库位块,然后进入客服待审核;不会首次分配、重建或更换已显示槽位。Order 仍待审核时允许提交并冻结到仓测量证据,但不得继续审核测量或进入计费、分柜和装柜。
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]*$'}}
@@ -2033,7 +2033,7 @@ components:
allowedActions:
type: array
description: 已同时按真实业务状态与当前用户权限裁剪
items: {type: string, enum: [EDIT_CONTAINER, MANAGE_CUSTOMS, CONFIRM_OUTBOUND, MANAGE_DESTINATION_DOCUMENTS, SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}
items: {type: string, enum: [EDIT_CONTAINER, MANAGE_CUSTOMS, CONFIRM_OUTBOUND, MANAGE_DESTINATION_DOCUMENTS, SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}
actualFacts: {$ref: '#/components/schemas/ContainerActualFacts'}
terminalDrayage: {$ref: '#/components/schemas/ContainerTerminalDrayage'}
ContainerWorkbenchStatusSummary:
@@ -2080,7 +2080,7 @@ components:
actualEmptyReturnAt: {type: string, format: date-time, nullable: true}
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
emptyReturnWarning: {type: object, nullable: true}
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}}
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}}
ContainerBooking:
type: object
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version]
@@ -74,6 +74,23 @@ paths:
content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmPreloadRequest'}}}
responses:
'200': {$ref: '#/components/responses/PreloadConfirmationResponse'}
/api/platform/v1/first-leg/loading-plans/{planId}/pallet-labels/prepare:
post:
summary: 生成并查询散货托盘码清单
description: 预装确认且已有真实柜号后,按柜号-FBA地址内部编号-三位序号生成稳定清单;一件代发不参与。内部编号缺失时拒绝生成。
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}]
responses:
'200': {description: 按 FBA 仓分组的散货托盘码清单, content: {application/json: {schema: {type: object}}}}
/api/platform/v1/first-leg/loading-plans/{planId}/pallet-labels/print:
post:
summary: 打印散货托盘码
description: 仓码和张数均为空时打印全部;均有值时打印该 FBA 仓从第1张开始的指定数量,用于补打。
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}]
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/PalletLabelPrintRequest'}}}
responses:
'200': {description: 100mm × 100mm 托盘码 PDF, content: {application/pdf: {schema: {type: string, format: binary}}}}
/api/platform/v1/first-leg/loading-lists:
get:
summary: 分页查询装柜清单
@@ -430,3 +447,9 @@ components:
voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次}
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本}
operationReason: {type: string, minLength: 1, maxLength: 500, nullable: true, description: 纠正已有真实集装箱号时必填;首次补录或保持当前柜号时可省略}
PalletLabelPrintRequest:
type: object
additionalProperties: false
properties:
destinationWarehouseCode: {type: string, maxLength: 64, nullable: true}
count: {type: integer, minimum: 1, maximum: 999, nullable: true}
@@ -88,16 +88,16 @@ paths:
'200': {description: Original file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/shipping-instruction:
get:
summary: Render the released booking as an export shipping instruction PDF
description: Firstleg maps the authoritative released aggregate to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF.
summary: Render the current saved booking as an export shipping instruction PDF
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF.
x-permission: platform:firstleg:ocean-booking:list
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
responses:
'200': {description: PDF or a business-error JSON envelope, content: {application/pdf: {schema: {type: string, format: binary}}, application/json: {schema: {type: object}}}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/bill-of-lading-confirmation:
get:
summary: Render the released booking as a bill-of-lading confirmation PDF
description: Firstleg maps the authoritative released aggregate to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF.
summary: Render the current saved booking as a bill-of-lading confirmation PDF
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF.
x-permission: platform:firstleg:ocean-booking:list
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
responses:
@@ -52,11 +52,63 @@ paths:
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}}
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-email:
post:
summary: 将当前车队提柜邮件加入发送队列
description: 初次选择车队不发信;此命令携带版本和幂等键,发送成功是任务已入队,不表示已送达。人工可使用最新版本重复发送提柜请求。更换车队时,原车队存在提柜请求邮件记录才原子入队取消邮件,并始终为新车队入队提柜请求;原车队从未下发时不发送取消邮件。同一车队重复选择不发信。
x-permission: platform:firstleg:terminal-drayage:fleet:edit
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}}}}}}
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
/api/platform/v1/terminal-drayages/{containerBookingId}/emails:
get:
summary: 查询最近100封车队邮件及发信开关
x-permission: platform:firstleg:terminal-drayage:list
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
responses:
'200':
description: 邮件记录;SENT表示SMTP已接受,不表示收件人已阅读
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data:
type: object
properties:
deliveryEnabled: {type: boolean}
records: {type: array, items: {$ref: '#/components/schemas/EmailTask'}}
/api/platform/v1/terminal-drayages/{containerBookingId}/emails/{mailId}/retry:
post:
summary: 核对失败邮件未收到后手动重试
description: 仅FAILED状态可重试,服务端校验mailId归属本柜;失败可能已经送达,须人工核对。同柜邮件依次发送,失败阻断后续邮件。
x-permission: platform:firstleg:terminal-drayage:fleet:edit
parameters:
- {$ref: '#/components/parameters/ContainerBookingId'}
- {name: mailId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses: {'200': {description: 已重新入队}}
components:
parameters:
ContainerBookingId: {name: containerBookingId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 255}}
schemas:
EmailTask:
type: object
properties:
id: {type: string}
containerBookingId: {type: string}
fleetSupplierId: {type: string}
fleetName: {type: string}
recipient: {type: string, format: email}
kind: {type: string, enum: [REQUEST_PICKUP, CANCEL_PICKUP]}
subject: {type: string}
body: {type: string}
status: {type: string, enum: [PENDING, SENDING, SENT, FAILED]}
attempts: {type: integer}
failureReason: {type: string, nullable: true}
createdAt: {type: string, format: date-time}
sentAt: {type: string, format: date-time, nullable: true}
FleetSelectionRequest:
type: object
required: [expectedVersion, fleetSupplierId]
@@ -104,7 +156,7 @@ components:
actualEmptyReturnAt: {type: string, format: date-time, nullable: true}
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
emptyReturnWarning: {$ref: '#/components/schemas/Warning'}
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}}
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}}
actualFacts: {$ref: '#/components/schemas/ContainerActualFacts'}
updatedAt: {type: string, format: date-time, nullable: true}
ContainerActualFacts:
@@ -127,7 +179,7 @@ components:
type: object
required: [action, fromStatus, toStatus, fromVersion, toVersion, operatorId, createdAt]
properties:
action: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}
action: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}
fromStatus: {type: string}
toStatus: {type: string}
fromVersion: {type: integer, minimum: 0}
@@ -23,6 +23,8 @@ class FbaPricingContractTest {
+ " 'firstleg:fba-pricing:confirm')");
assertPricingAuthority("send", "hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
+ " 'firstleg:inbound:send-fee')");
assertPricingAuthority("adjustPublished", "hasAnyAuthority('firstleg:fba-pricing:adjust',"
+ " 'firstleg:inbound:confirm-send-fee')");
}
@Test
@@ -75,6 +75,9 @@ class LoadingListControllerContractTest {
.contains("PDA任务尚未领取且无任何物理作业事实")
.contains("/loading-plans/{planId}/confirm")
.contains("/loading-plans/{planId}/lifecycle")
.contains("/loading-plans/{planId}/pallet-labels/prepare")
.contains("/loading-plans/{planId}/pallet-labels/print")
.contains("柜号-FBA地址内部编号-三位序号")
.contains("不得以错误文案或 A0404 猜测阶段")
.contains("confirmationStatus")
.contains("canConfirm")
@@ -123,6 +126,8 @@ class LoadingListControllerContractTest {
"/api/platform/v1/first-leg/loading-plans/{planId}/lifecycle",
"/api/platform/v1/first-leg/loading-plans/{planId}/transport",
"/api/platform/v1/first-leg/loading-plans/{planId}/confirm",
"/api/platform/v1/first-leg/loading-plans/{planId}/pallet-labels/prepare",
"/api/platform/v1/first-leg/loading-plans/{planId}/pallet-labels/print",
"/api/platform/v1/first-leg/loading-lists",
"/api/platform/v1/first-leg/loading-lists/{planId}");
Map<String, Object> components = stringMap(document.get("components"));
@@ -132,7 +137,7 @@ class LoadingListControllerContractTest {
"ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail",
"ConfirmedDetail", "TransportUpdateRequest", "PreloadReadiness",
"ConfirmPreloadRequest", "PreloadConfirmation", "PreloadConfirmationEnvelope",
"LifecycleResolution", "LifecycleResolutionEnvelope");
"LifecycleResolution", "LifecycleResolutionEnvelope", "PalletLabelPrintRequest");
assertThat(properties(schemas, "LifecycleResolution")).containsOnlyKeys(
"planId", "containerBookingId", "warehouseId", "planNo", "planStatus",
"detailResource", "requiredPermission");
@@ -1,6 +1,5 @@
package com.baiyue.firstleg.oceanbooking.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.PrintPdfClient;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
@@ -22,7 +21,6 @@ import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@@ -60,11 +58,34 @@ class OceanBookingPrintServiceTest {
});
}
@Test void printRejectsDraftWithoutCallingPrintService() {
when(bookings.detail(10L)).thenReturn(detail("DRAFT"));
assertThatThrownBy(() -> service.billConfirmation(10L, "req-2"))
.isInstanceOf(BizException.class).hasMessageContaining("放舱后");
verifyNoInteractions(print);
@Test void bothDocumentsPrintDraftWithMissingReleaseData() {
Detail detail = detail("DRAFT");
detail.oceanBooking().setBillOfLadingNo(null);
detail.oceanBooking().setPlaceOfDelivery(null);
detail.oceanBooking().setServiceTerm(null);
detail.oceanBooking().setFreightTerm(null);
detail = new Detail(detail.oceanBooking(), List.of(detail.parties().get(0)),
detail.containerRequirements(), List.of(), List.of(), List.of());
when(bookings.detail(10L)).thenReturn(detail);
when(print.generateFreightA4Documents(eq("token"), eq("req-2"), any()))
.thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body(new byte[]{1, 2, 3}));
assertThat(service.shippingInstruction(10L, "req-2").content()).isNotEmpty();
assertThat(service.billConfirmation(10L, "req-2").content()).isNotEmpty();
ArgumentCaptor<PrintPdfClient.FreightA4PdfRequest> request =
ArgumentCaptor.forClass(PrintPdfClient.FreightA4PdfRequest.class);
verify(print, times(2)).generateFreightA4Documents(eq("token"), eq("req-2"), request.capture());
for (var captured : request.getAllValues()) {
assertThat(captured.documents().get(0).fields())
.containsEntry("shipper", "Shipper\nAddress")
.containsEntry("consignee", "—")
.containsEntry("notifyParty", "—")
.containsEntry("placeOfDelivery", "—")
.containsEntry("serviceTerm", "—")
.containsEntry("freightTerm", "—");
}
assertThat(request.getValue().documents().get(0).fields()).containsEntry("bookingNumber", "—");
}
private static Detail detail(String status) {
@@ -390,7 +390,7 @@ class OceanBookingServiceTest {
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(row));
when(warehouses.active(any(), eq(0L), eq(701L))).thenReturn(ApiResponse.success(
new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse(
701L, 0L, "SZX", "深圳中转仓", "DOMESTIC", "ACTIVE"))));
701L, 0L, "SZX", null, "深圳中转仓", "DOMESTIC", "ACTIVE"))));
when(suppliers.booking(81L, null)).thenReturn(
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("MATSON")));
when(partners.active(any(), eq(0L), eq(71L))).thenReturn(ApiResponse.success(
@@ -50,6 +50,8 @@ class CarrierScheduleNodeOutboxServiceTest {
assertThat(row.getEventType()).isEqualTo(EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED);
assertThat(envelope.eventVersion()).isEqualTo(1);
assertThat(envelope.aggregateType()).isEqualTo("CONTAINER_BOOKING");
assertThat(envelope.tenantId()).isEqualTo("0");
assertThat(row.getTenantId()).isZero();
assertThat(envelope.aggregateId()).isEqualTo("2088000000000000003");
assertThat(envelope.aggregateVersion()).isEqualTo(7);
assertThat(envelope.handoverVersion()).isEqualTo(8);
@@ -57,6 +59,7 @@ class CarrierScheduleNodeOutboxServiceTest {
assertThat(envelope.payload().path("sourceContainerBookingId").asText())
.isEqualTo("2088000000000000003");
assertThat(envelope.payload().has("containerBookingId")).isFalse();
assertThat(envelope.payload().has("customerId")).isFalse();
assertThat(envelope.payload().path("actualAt").asText()).isEqualTo("2026-08-25T01:00:00Z");
}
@@ -468,7 +468,7 @@ class ContainerServiceTest {
String type,
String name) {
return ApiResponse.success(new WarehouseMasterDataClient.Detail(
new WarehouseMasterDataClient.Warehouse(warehouseId, 0L, "CN-SHA-01", name, type, "ACTIVE")));
new WarehouseMasterDataClient.Warehouse(warehouseId, 0L, "CN-SHA-01", null, name, type, "ACTIVE")));
}
private static ContainerBookingEntity booking(String status, int version) {
@@ -788,7 +788,7 @@ class FbaPricingServiceTest {
InboundFeeEntity first = savedFee("CALCULATED");
first.setActualWeightKg(new BigDecimal("90.000")); first.setActualVolumeCbm(new BigDecimal("0.1000"));
first.setMeasurementId(301L); first.setMeasurementSource("OUTER");
InboundFeeEntity second = savedFee("CONFIRMED");
InboundFeeEntity second = savedFee("CALCULATED");
second.setId(502L); second.setInboundId(30002L); second.setActualWeightKg(new BigDecimal("110.000"));
second.setActualVolumeCbm(new BigDecimal("0.1000")); second.setMeasurementId(302L);
second.setMeasurementSource("ACTUAL");
@@ -834,20 +834,61 @@ class FbaPricingServiceTest {
}
@Test
@DisplayName("柜内任一已发送或客户确认费用阻断整组重算")
void rejectsContainerRecalculationWhenAnyFeeIsImmutable() {
@DisplayName("柜内已发送费用保持锁定且不阻断其他未锁定费用重算")
void keepsImmutableContainerFeeAndRecalculatesOnlyUnlockedMembers() {
readyRepository();
InboundPricingEntity inbound = inbound("100", "0.1");
InboundPricingEntity lockedInbound = inbound("100", "0.1");
InboundPricingEntity unlockedInbound = inbound("80", "0.1");
unlockedInbound.setId(30002L); unlockedInbound.setInboundCode("RK-002");
InboundFeeEntity sent = savedFee("SENT");
sent.setActualWeightKg(new BigDecimal("100")); sent.setActualVolumeCbm(new BigDecimal("0.1"));
InboundFeeEntity calculated = savedFee("CALCULATED");
calculated.setId(502L); calculated.setInboundId(30002L);
calculated.setActualWeightKg(new BigDecimal("80")); calculated.setActualVolumeCbm(new BigDecimal("0.1"));
when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of(
memberRow(901L, INBOUND, "100", "0.1"), memberRow(901L, 30002L, "80", "0.1")
));
when(repository.findInbound(TENANT, INBOUND)).thenReturn(lockedInbound);
when(repository.findInbound(TENANT, 30002L)).thenReturn(unlockedInbound);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent);
when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(calculated);
when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2);
when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1);
when(repository.supersedeCurrent(TENANT, 502L, "CALCULATED")).thenReturn(1);
when(repository.markInboundContainerFeeRecalculated(eq(TENANT), eq(30002L), anyString(), anyString(), any()))
.thenReturn(1);
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("180", "8", "1440"));
when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn(
new ContainerTierAmountAllocator.Allocation(new BigDecimal("180"), new BigDecimal("1440"),
Map.of(INBOUND, new BigDecimal("800.00"), 30002L, new BigDecimal("640.00"))));
var result = service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-2");
assertThat(result.status()).isEqualTo(FbaPricingService.ContainerFeeRecalculationStatus.RECALCULATED);
assertThat(result.recalculatedCount()).isEqualTo(1);
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository).insertFee(fee.capture());
assertThat(fee.getValue().getInboundId()).isEqualTo(30002L);
assertThat(fee.getValue().getSubtotalAmount()).isEqualByComparingTo("640.00");
verify(repository, never()).supersedeCurrent(TENANT, 501L, "SENT");
verify(priceBookEstimator).estimate(eq(TENANT), any());
}
@Test
@DisplayName("柜内费用全部锁定时关系变更无需重算且不报错")
void skipsContainerRecalculationWhenEveryFeeIsImmutable() {
InboundPricingEntity inbound = inbound("100", "0.1");
InboundFeeEntity confirmed = savedFee("CONFIRMED");
when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of(
memberRow(901L, INBOUND, "100", "0.1")
));
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(confirmed);
assertThatThrownBy(() -> service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-2"))
.isInstanceOf(BizException.class).hasMessageContaining("已发送或客户已确认");
var result = service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-locked");
assertThat(result.status()).isEqualTo(FbaPricingService.ContainerFeeRecalculationStatus.NOT_APPLICABLE);
assertThat(result.recalculatedCount()).isZero();
verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), anyString());
verify(repository, never()).insertFee(any());
verify(priceBookEstimator, never()).estimate(anyLong(), any());
@@ -157,6 +157,26 @@ class InboundOrderCreationServiceTest {
verify(orders, times(2)).update(any(), any(Wrapper.class));
}
@Test
void approvalKeepsMeasurementAlreadyCapturedBeforeOrderReview() {
InboundOrderEntity prepared = inbound(900L, "PCHK2608140001", "UNPRICED", null, null);
prepared.setProcessStatus("MEASURING");
prepared.setOrderStatusSnapshot("SUBMITTED");
when(orders.selectOne(any(Wrapper.class))).thenReturn(prepared);
when(orders.update(any(), any(Wrapper.class))).thenReturn(1);
when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L));
assertThat(service.createFromApprovedOrder(approvedCommand("PCHK2608140001"))).isEqualTo(900L);
verify(orders, times(2)).update(any(), any(Wrapper.class));
ArgumentCaptor<InboundStatusHistoryEntity> historyCaptor =
ArgumentCaptor.forClass(InboundStatusHistoryEntity.class);
verify(histories).insert(historyCaptor.capture());
assertThat(historyCaptor.getValue().getFromStatus()).isEqualTo("MEASURING");
assertThat(historyCaptor.getValue().getToStatus()).isEqualTo("MEASURING");
assertThat(historyCaptor.getValue().getAction()).isEqualTo("ORDER_APPROVED");
}
private static InboundOrderEntity inbound(long id, String code, String feeStatus,
Long carrierId, String carrierName) {
InboundOrderEntity inbound = new InboundOrderEntity();
@@ -9,6 +9,7 @@ import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -30,14 +31,19 @@ class LoadingCompletedOutboxServiceTest {
private final LoadingCompletedEventMapper snapshots = mock(LoadingCompletedEventMapper.class);
private final InboundOutboxMapper outbox = mock(InboundOutboxMapper.class);
private final LoadingCartonManifestMapper manifests = mock(LoadingCartonManifestMapper.class);
private final LoadingPalletLabelMapper palletLabels = mock(LoadingPalletLabelMapper.class);
private final LoadingPalletLabelService palletLabelService = mock(LoadingPalletLabelService.class);
private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
private final LoadingCompletedOutboxService service = new LoadingCompletedOutboxService();
@BeforeEach void setup() {
ReflectionTestUtils.setField(service,"snapshotMapper",snapshots);
ReflectionTestUtils.setField(service,"cartonManifestMapper",manifests);
ReflectionTestUtils.setField(service,"palletLabelMapper",palletLabels);
ReflectionTestUtils.setField(service,"palletLabelService",palletLabelService);
ReflectionTestUtils.setField(service,"outboxMapper",outbox);
ReflectionTestUtils.setField(service,"objectMapper",mapper);
when(palletLabels.labels(anyLong())).thenReturn(List.of());
when(manifests.labelPool(anyLong(), anyLong())).thenAnswer(invocation -> List.of(
label(101, "BOX-101"), label(102, "BOX-102"),
label(202, "BOX-202"), label(302, "BOX-302")));
@@ -30,6 +30,7 @@ import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@@ -315,6 +316,16 @@ class LoadingListServiceTest {
verify(loadingCompletedOutboxService).publish(10L);
}
@Test
void completedEventReadinessMustAllowPalletLabelFreeze() throws Exception {
Transactional transaction = LoadingListService.class
.getDeclaredMethod("requireCompletedEventReady", long.class)
.getAnnotation(Transactional.class);
assertThat(transaction).isNotNull();
assertThat(transaction.readOnly()).isFalse();
}
@Test
void shouldRejectAutomaticCompletionWhenPdaItemsRemain() {
when(pickRepository.allPlanItemsPicked(10L)).thenReturn(false);
@@ -0,0 +1,61 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.common.core.api.ApiResponse;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class LoadingPalletLabelServiceTest {
@Test
void generatesStableIndexesPerContainerAndFbaAndSkipsDropship() {
LoadingPalletLabelMapper mapper = mock(LoadingPalletLabelMapper.class);
LoadingPalletLabelService service = new LoadingPalletLabelService();
ReflectionTestUtils.setField(service, "mapper", mapper);
WarehouseMasterDataClient warehouseClient = mock(WarehouseMasterDataClient.class);
ReflectionTestUtils.setField(service, "warehouseClient", warehouseClient);
ReflectionTestUtils.setField(service, "internalToken", "test-token");
List<LoadingPalletLabelMapper.LabelRow> saved = new ArrayList<>();
when(mapper.lockSources(10L)).thenReturn(List.of(source(3, "LAX9", 1, "STANDARD"),
source(1, "ONT8", 2, "STANDARD"), source(2, "ONT8", 1, "STANDARD"),
source(4, "ONT8", 4, "ONE_CLICK_DROPSHIP")));
when(mapper.labels(10L)).thenAnswer(ignored -> List.copyOf(saved));
when(mapper.insert(any())).thenAnswer(invocation -> { saved.add(invocation.getArgument(0)); return 1; });
when(warehouseClient.active("test-token", 0L, 101L)).thenReturn(warehouse("LAX9", "154"));
when(warehouseClient.active("test-token", 0L, 102L)).thenReturn(warehouse("ONT8", "CADF"));
var manifest = service.prepare(10L, 20L);
assertThat(manifest.totalCount()).isEqualTo(4);
assertThat(manifest.groups()).extracting(group -> group.destinationWarehouseCode() + ":" + group.totalCount())
.containsExactly("LAX9:1", "ONT8:3");
assertThat(manifest.groups()).extracting(group -> group.destinationWarehouseInternalCode())
.containsExactly("154", "CADF");
assertThat(saved).extracting(LoadingPalletLabelMapper.LabelRow::getPalletCode)
.containsExactly("MSCU6639870-154-001", "MSCU6639870-CADF-001",
"MSCU6639870-CADF-002", "MSCU6639870-CADF-003");
}
private LoadingPalletLabelMapper.SourceRow source(long itemId, String destination, int pallets, String fulfillment) {
var row = new LoadingPalletLabelMapper.SourceRow(); row.setPlanId(10L); row.setPlanNo("LP-1");
row.setPlanStatus("LOADING"); row.setContainerNo("MSCU6639870"); row.setWarehouseId(20L);
row.setItemId(itemId); row.setAccountId(100L); row.setInboundId(itemId + 1000);
row.setInboundCode("RK-" + itemId); row.setDestinationWarehouseCode(destination);
row.setDestinationWarehouseId("LAX9".equals(destination) ? 101L : 102L);
row.setDestinationWarehouseName(destination); row.setPalletCount(pallets);
row.setFulfillmentType(fulfillment); row.setDestinationType("FBA"); return row;
}
private ApiResponse<WarehouseMasterDataClient.Detail> warehouse(String code, String internalCode) {
return ApiResponse.success(new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse(
"LAX9".equals(code) ? 101L : 102L, 0L, code, internalCode, code, "FBA", "ACTIVE")));
}
}
@@ -65,6 +65,21 @@ class MeasurementReviewServiceTest {
verify(repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
}
@Test
@DisplayName("订单待审核时保留测量数据但不能审核测量")
void shouldBlockMeasurementReviewUntilOrderIsApproved() {
Fixture fixture = fixture(false);
fixture.order.setOrderStatusSnapshot("SUBMITTED");
assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L,
"approve-before-order", new MeasurementReviewRequest(20L, 3, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("订单尚未审核通过");
verify(fixture.repository, never()).transitionReview(
anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
}
@Test
@DisplayName("审核通过是测量到计费的唯一正常推进关口")
void shouldApproveOnlyPendingSubmittedMeasurement() {
@@ -249,6 +264,7 @@ class MeasurementReviewServiceTest {
when(properties.isAllowRejectedMeasurement()).thenReturn(allowRelease);
InboundOrderEntity order = new InboundOrderEntity();
order.setProcessStatus("MEASURE_PENDING");
order.setOrderStatusSnapshot("APPROVED");
order.setMeasureReviewStatus("PENDING");
order.setTotalPackageCount(5);
order.setActualGrossWeightKg(new BigDecimal("1787.500"));
@@ -28,6 +28,9 @@ class TerminalDrayageOpenApiContractTest {
assertPermission("detail", "platform:firstleg:terminal-drayage:list");
assertPermission("history", "platform:firstleg:terminal-drayage:list");
assertPermission("selectFleet", "platform:firstleg:terminal-drayage:fleet:edit");
assertPermission("sendPickupEmail", "platform:firstleg:terminal-drayage:fleet:edit");
assertPermission("retryEmail", "platform:firstleg:terminal-drayage:fleet:edit");
assertPermission("emails", "platform:firstleg:terminal-drayage:list");
assertPermission("schedulePickup", "platform:firstleg:terminal-drayage:pickup-appointment:edit");
assertPermission("recordPickup", "platform:firstleg:terminal-drayage:pickup:confirm");
assertPermission("recordEmptyReturn", "platform:firstleg:terminal-drayage:empty-return:confirm");
@@ -8,6 +8,44 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class TerminalDrayageSqlContractTest {
@Test
void mailProjectionMatchesRecordAndPendingQueryBlocksEarlierUnsentMail() throws Exception {
var mapperType = com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class;
var fields = java.util.Arrays.stream(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.MailTask.class.getRecordComponents())
.map(java.lang.reflect.RecordComponent::getName).toList();
for (String name : java.util.List.of("mails", "lastPickupMail", "pendingMails")) {
var method = java.util.Arrays.stream(mapperType.getMethods()).filter(value -> value.getName().equals(name)).findFirst().orElseThrow();
String sql = String.join(" ", method.getAnnotation(org.apache.ibatis.annotations.Select.class).value());
String projection = sql.substring(sql.indexOf("SELECT")+6, sql.indexOf("FROM"));
var selected = java.util.Arrays.stream(projection.split(",")).map(String::trim)
.map(value -> value.contains(" AS ") ? value.substring(value.indexOf(" AS ")+4) : value.replace("m.", "")).toList();
assertThat(selected).isEqualTo(fields);
if (name.equals("pendingMails")) assertThat(sql).contains("earlier.container_booking_id=m.container_booking_id", "earlier.id<m.id", "earlier.status<>'SENT'");
}
for (Path path : java.util.List.of(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"), Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))) {
assertThat(Files.readString(path)).contains(
"CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (",
"CHECK(status IN ('PENDING','SENDING','SENT','FAILED'))",
"('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')");
}
}
@Test
void bothSqlEntrypointsAllowReturnWithoutPickupAndRetainKnownTimeOrdering() throws Exception {
for (Path path : java.util.List.of(
Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"),
Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))) {
String sql = Files.readString(path);
assertThat(sql).contains("actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at")
.doesNotContain("actual_empty_return_at IS NULL OR actual_pickup_at IS NOT NULL");
}
String release = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
assertThat(release).contains("DROP CHECK chk_terminal_drayage_times",
"ADD CONSTRAINT chk_terminal_drayage_times CHECK(",
"DROP CHECK chk_terminal_drayage_history_action",
"ADD CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN");
}
@Test
void schemaKeepsOneAggregatePerContainerAndImmutableAudit() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"));
@@ -103,7 +103,7 @@ class ContainerWorkbenchServiceTest {
var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class);
var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
var realDrayages = new TerminalDrayageService(mapper,
mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, json);
mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, json, mock(TerminalDrayageMailService.class));
ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages);
ContainerBookingEntity container = container();
container.setUnloadingCompletedAt(LocalDateTime.of(2026, 9, 5, 8, 0));
@@ -148,7 +148,7 @@ class ContainerWorkbenchServiceTest {
var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class);
var realDrayages = new TerminalDrayageService(mapper,
mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules,
new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(), mock(TerminalDrayageMailService.class));
ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages);
ContainerBookingEntity container = container();
LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 5, 8, 0);
@@ -0,0 +1,47 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
import org.junit.jupiter.api.Test;
import org.springframework.mail.MailSendException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class TerminalDrayageMailDispatcherTest {
TerminalDrayageMapper mapper = mock(TerminalDrayageMapper.class);
JavaMailSenderImpl sender = mock(JavaMailSenderImpl.class);
TerminalDrayageMailDispatcher worker = spy(new TerminalDrayageMailDispatcher(mapper));
void setup() {
ReflectionTestUtils.setField(worker,"username","operations@example.com");
doReturn(sender).when(worker).sender();
when(mapper.pendingMails()).thenReturn(List.of(new TerminalDrayageMapper.MailTask(
1L,11L,2L,"Fleet","fleet@example.com","REQUEST_PICKUP","Pickup","Body","PENDING",0,null,null,null)));
when(mapper.claimMail(1L)).thenReturn(1);
when(mapper.completeMail(1L)).thenReturn(1);
}
@Test void onlyClaimsOnceAndMarksSentAfterSmtpAccepted() {
setup(); worker.dispatch();
var ordered = inOrder(mapper,sender);
var sent = org.mockito.ArgumentCaptor.forClass(SimpleMailMessage.class);
ordered.verify(mapper).claimMail(1L);
ordered.verify(sender).send(sent.capture());
assertThat(sent.getValue().getFrom()).isEqualTo("operations@example.com");
ordered.verify(mapper).completeMail(1L);
when(mapper.claimMail(1L)).thenReturn(0);
worker.dispatch();
verify(sender,times(1)).send(any(SimpleMailMessage.class));
}
@Test void smtpFailureRequiresReviewInsteadOfReportingSuccess() {
setup(); doThrow(new MailSendException("uncertain")).when(sender).send(any(SimpleMailMessage.class));
worker.dispatch();
verify(mapper).failMail(eq(1L),contains("结果不确定"));
verify(mapper,never()).completeMail(anyLong());
}
}
@@ -0,0 +1,77 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class TerminalDrayageMailServiceTest {
TerminalDrayageMapper mapper = mock(TerminalDrayageMapper.class);
WarehouseSupplierClient suppliers = mock(WarehouseSupplierClient.class);
TerminalDrayageMailService service = new TerminalDrayageMailService(mapper, suppliers);
@BeforeEach void setup() {
ReflectionTestUtils.setField(service, "internalToken", "test-token");
when(suppliers.truckingContact("test-token", 1L, false)).thenReturn(ApiResponse.success(
new WarehouseSupplierClient.TruckingContact(1L, "Old Fleet", "old@example.com")));
when(suppliers.truckingContact("test-token", 2L, true)).thenReturn(ApiResponse.success(
new WarehouseSupplierClient.TruckingContact(2L, "New Fleet", "new@example.com")));
}
@Test void replacementQueuesCancellationThenRequestWithSeparateFrozenRecipients() {
when(mapper.lastPickupMail(11L, 1L)).thenReturn(new TerminalDrayageMapper.MailTask(
90L,11L,1L,"Old Fleet","old@example.com","REQUEST_PICKUP","subject","body","SENT",1,null,null,null));
service.replaceFleet(11L, "MSCU1234567", 1L, 2L, 7L);
var order = inOrder(mapper);
order.verify(mapper).insertMail(anyLong(), eq(11L), eq(1L), eq("Old Fleet"), eq("old@example.com"),
eq("CANCEL_PICKUP"), eq("Cancel port pickup - MSCU1234567"), contains("Please cancel"), eq(7L));
order.verify(mapper).insertMail(anyLong(), eq(11L), eq(2L), eq("New Fleet"), eq("new@example.com"),
eq("REQUEST_PICKUP"), eq("Port pickup request - MSCU1234567"), contains("Please arrange"), eq(7L));
}
@Test void replacementWithoutPreviousPickupMailQueuesOnlyNewRequest() {
service.replaceFleet(11L, "MSCU1234567", 1L, 2L, 7L);
verify(mapper, never()).insertMail(anyLong(), eq(11L), eq(1L), anyString(), anyString(),
eq("CANCEL_PICKUP"), anyString(), anyString(), eq(7L));
verify(mapper).insertMail(anyLong(), eq(11L), eq(2L), eq("New Fleet"), eq("new@example.com"),
eq("REQUEST_PICKUP"), eq("Port pickup request - MSCU1234567"), contains("Please arrange"), eq(7L));
verify(suppliers, never()).truckingContact("test-token", 1L, false);
}
@Test void cancellationUsesOriginalRecipientEvenIfSupplierEmailChanged() {
when(mapper.lastPickupMail(11L, 1L)).thenReturn(new TerminalDrayageMapper.MailTask(
90L,11L,1L,"Frozen Fleet","original@example.com","REQUEST_PICKUP","subject","body","SENT",1,null,null,null));
service.replaceFleet(11L, "MSCU1234567", 1L, 2L, 7L);
verify(suppliers, never()).truckingContact("test-token", 1L, false);
verify(mapper).insertMail(anyLong(), eq(11L), eq(1L), eq("Frozen Fleet"), eq("original@example.com"),
eq("CANCEL_PICKUP"), anyString(), anyString(), eq(7L));
}
@Test void missingNewEmailQueuesNeitherMail() {
when(suppliers.truckingContact("test-token", 2L, true)).thenReturn(ApiResponse.success(
new WarehouseSupplierClient.TruckingContact(2L, "New Fleet", null)));
assertThatThrownBy(() -> service.replaceFleet(11L, "MSCU1234567", 1L, 2L, 7L)).isInstanceOf(BizException.class);
verify(mapper, never()).insertMail(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString(), anyString(), anyString(), anyLong());
}
@ParameterizedTest
@ValueSource(strings={"", "not-an-email", "a@example.com,b@example.com", "a@example.com\r\nBcc: b@example.com", "Name <a@example.com>"})
void rejectsInvalidOrMultipleRecipients(String email) {
assertThatThrownBy(() -> TerminalDrayageMailService.validateAddress(email)).isInstanceOf(BizException.class);
}
@Test void retriesOnlyFailedMailBelongingToContainer() {
assertThatThrownBy(() -> service.retry(11L, 55L, 7L)).isInstanceOf(BizException.class);
when(mapper.retryMail(11L,55L,7L)).thenReturn(1);
service.retry(11L,55L,7L);
}
}
@@ -37,6 +37,7 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class TerminalDrayageServiceTest {
@@ -45,6 +46,7 @@ class TerminalDrayageServiceTest {
private WarehouseSupplierSnapshotService suppliers;
private CarrierScheduleQueryService carrierSchedules;
private TerminalDrayageService service;
private TerminalDrayageMailService mails;
private Deque<ContainerActualFacts> facts;
@BeforeEach
@@ -57,7 +59,8 @@ class TerminalDrayageServiceTest {
if (facts.isEmpty()) return List.of();
return List.of(facts.size() > 1 ? facts.removeFirst() : facts.peekFirst());
});
service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules());
mails = mock(TerminalDrayageMailService.class);
service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules(), mails);
service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC));
}
@@ -95,7 +98,7 @@ class TerminalDrayageServiceTest {
assertThat(result.version()).isEqualTo(1);
assertThat(result.fleetSupplierId()).isEqualTo(91L);
assertThat(result.fleetSupplierName()).isEqualTo("服务端车队");
assertThat(result.allowedActions()).containsExactlyInAnyOrder("SELECT_FLEET", "SCHEDULE_PICKUP");
assertThat(result.allowedActions()).containsExactlyInAnyOrder("SELECT_FLEET", "SCHEDULE_PICKUP", "SEND_PICKUP_EMAIL");
verify(mapper).insertHistory(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString(),
anyInt(), anyInt(), any(), anyLong(), anyString());
verify(mapper).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong());
@@ -343,6 +346,53 @@ class TerminalDrayageServiceTest {
assertThat(result.version()).isEqualTo(4);
}
@Test
void selectingSameFleetDoesNotQueueMailOrChangeVersion() {
when(mapper.lockContext(11L)).thenReturn(context("ARRIVED_DESTINATION_PORT", null));
when(mapper.lock(11L)).thenReturn(row(3));
when(suppliers.trucking(91L, null)).thenReturn(new WarehouseSupplierClient.Snapshot(91L,"车队","TRUCKING","ACTIVE",List.of()));
service.selectFleet(11L,7L,"admin","same-fleet",new FleetSelectionRequest(3,91L,null,null));
verifyNoInteractions(mails);
verify(mapper,never()).updateAggregate(any(),anyInt(),anyLong(),anyString());
}
@Test
void fleetReplacementQueuesBothEmailsBeforeSavingNewFleet() {
Context context = context("ARRIVED_DESTINATION_PORT",null);
when(mapper.lockContext(11L)).thenReturn(context);
when(mapper.context(11L)).thenReturn(context);
TerminalDrayageEntity row = row(3);
when(mapper.lock(11L)).thenReturn(row);
when(mapper.find(11L)).thenReturn(row);
when(mapper.updateAggregate(any(),anyInt(),anyLong(),anyString())).thenReturn(1);
when(suppliers.trucking(92L,null)).thenReturn(new WarehouseSupplierClient.Snapshot(92L,"新车队","TRUCKING","ACTIVE",List.of()));
service.selectFleet(11L,7L,"admin","change-fleet",new FleetSelectionRequest(3,92L,null,null));
var ordered = org.mockito.Mockito.inOrder(mails,mapper);
ordered.verify(mails).replaceFleet(11L,"MSCU1234567",91L,92L,7L);
ordered.verify(mapper).updateAggregate(any(),org.mockito.ArgumentMatchers.eq(3),org.mockito.ArgumentMatchers.eq(7L),org.mockito.ArgumentMatchers.eq("admin"));
assertThat(row.getFleetSupplierId()).isEqualTo(92L);
}
@Test
void manualPickupEmailRejectsStaleClickAndAllowsRepeatWithLatestVersion() {
Context context = context("ARRIVED_DESTINATION_PORT",null);
when(mapper.lockContext(11L)).thenReturn(context);
when(mapper.context(11L)).thenReturn(context);
TerminalDrayageEntity row = row(3);
when(mapper.lock(11L)).thenReturn(row);
when(mapper.find(11L)).thenReturn(row);
when(mapper.updateAggregate(any(),anyInt(),anyLong(),anyString())).thenAnswer(invocation -> {
row.setVersion(invocation.getArgument(1, Integer.class) + 1);
return 1;
});
var request = new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest(3);
service.sendPickupEmail(11L,7L,"admin","mail-1",request);
assertThatThrownBy(() -> service.sendPickupEmail(11L,7L,"admin","mail-2",request)).isInstanceOf(BizException.class);
service.sendPickupEmail(11L,7L,"admin","mail-3",
new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.EmailRequest(4));
verify(mails,times(2)).request(11L,"MSCU1234567",91L,7L);
}
private Context context(String node, LocalDateTime unloadedAt) {
int sequenceVersion = switch (node) {
case "PICKED_UP" -> 5;
@@ -39,6 +39,8 @@ VALUES
(5107061,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:create','创建海外库位',NULL,'POST',761,'ACTIVE',1,0),
(5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'ACTIVE',1,0),
(5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,0),
(5107190,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:return-inventory:list','退货库存管理','/overseas-warehouse/return-inventory','GET',890,'ACTIVE',1,0),
(5107191,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:return-cartons:list','退货箱号管理','/overseas-warehouse/return-cartons','GET',891,'ACTIVE',1,0),
(5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,0),
(5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,0),
(5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,0),
@@ -107,7 +107,12 @@ class ContainerWorkspacePermissionSqlContractTest {
assertThat(start).isGreaterThanOrEqualTo(0);
assertThat(bodyStart).isGreaterThan(start);
assertThat(end).isGreaterThan(bodyStart);
assertThat(unified.substring(bodyStart, end).trim()).isEqualTo(sql().trim());
assertThat(normalizeLineEndings(unified.substring(bodyStart, end)).trim())
.isEqualTo(normalizeLineEndings(sql()).trim());
}
private static String normalizeLineEndings(String value) {
return value.replace("\r\n", "\n");
}
private static String sql() throws Exception {
@@ -41,7 +41,7 @@ class OverseasPhase4PermissionSqlContractTest {
assertThat(sql).contains(REQUIRED.toArray(String[]::new));
assertThat(sql).contains("'lastleg:pda:operate'");
assertPdaPickerGrant(sql);
assertThat(catalog).hasSize(56).noneMatch(code -> code.endsWith(":view"));
assertThat(catalog).hasSize(58).noneMatch(code -> code.endsWith(":view"));
assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1");
assertThat(sql).doesNotContain(":carton:", ":product:");
assertThat(catalog).contains("platform:overseas-warehouse:delivery:list");
+6 -4
View File
@@ -9,6 +9,8 @@
## 快速索引
头程来源托盘快照:`service/LoadingCompletedConsumer.java`、`mapper/LastlegSourcePalletMapper.java`;来源标签随装柜完成事件落入 `ll_order_source_pallet`,后续由 `inboundplan/service/InboundPlanService.java` 与 `ContainerInboundPlanConversionCoordinator.java` 传递给海外资源批量预留。排查标签跨区丢失时沿此链路核对,不能在尾程页面重新生成头程托盘码。
| 功能 | 入口 |
|---|---|
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
@@ -26,7 +28,7 @@
| 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 |
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`,合托只作现场可选提示;首扫确认已预留的真实 pallet/location/lock 绑定,不得重新分配计划资源 |
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;首扫确认已预留的真实 pallet/location/lock 绑定,不得重新分配计划资源 |
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
| 拆柜打托成本与客户应收分摊 | `inboundplan/service/DestuffingPalletCostJob.java`、`DestuffingPalletCostService.java`;仅全柜所有 PDA 入仓任务完成后执行,按实物托盘单价汇总并按客户实收 CBM 分摊,尾差落最后一位客户;每份分摊发布 `OVERSEAS_CONTAINER_FEE/DESTUFF_PALLET` 应收费用来源,不直接改国内钱包 |
@@ -35,7 +37,7 @@
| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,排车和出库使用独立 PDA Controller |
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核通过进入 `CUSTOMER_CONFIRM_PENDING`,客户查看照片确认后进入 `READY_FOR_OUTBOUND`:仅卡车派送为 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 |
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token |
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 |
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 |
| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,查询、预排与执行边界见“海外仓全流程开发路由” |
| 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`MerchantDeliveryController.java`、`OutboundPdaController.java` |
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
@@ -95,9 +97,9 @@
- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。
- IAM 安全事件只能进入 `/internal/lastleg/security/v1/events`;通用 `/internal/lastleg/events` 只白名单接收装柜完成与实际船节点事件,禁止把 IAM 事件交给 `LoadingCompletedConsumer`。
- Lastleg 安全投影只接受 `ACTIVE`/`DISABLED` 两态安全语义;客户建档和审核的 `PROFILE_REQUIRED`/`PENDING`/`REJECTED`/`ARCHIVED` 由 IAM 在发布前统一折叠为 `DISABLED`,Lastleg 不兼容或解释国内工作流状态。
- 开发基线只为国内固定 `authUserId=1000001`、`tenantId=0` 的 `platform_admin` 写 USER/AUTHORIZATION version=0 投影;使用 `INSERT IGNORE`,后续 IAM version>=1 事件单调覆盖。不得扩大为历史账号运行时补齐或缺失投影默认放行。
- 开发/测试空库基线只为国内固定 `authUserId=1000001`、`tenantId=0` 的 `platform_admin` 写 USER/AUTHORIZATION version=0 投影;AUTHORIZATION 必须携带当前 `PLATFORM_ADMIN` 角色及按 IAM 跨区前缀筛选的完整权限快照,统一 SQL 重放只修正仍处于固定 baseline version=0 的该行,后续 IAM version>=1 事件单调覆盖。不得扩大为其他历史账号运行时补齐或缺失投影默认放行。
- PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。
- 海外 PDA 柜号签收只允许平台 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 使用 `lastleg:pda:operate`;国内头程 PDA 角色不得进入。PDA 不接收 `customerId`,候选跨客户读取,选柜时从柜内计划唯一解析并冻结真实客户。未完成会话按平台操作账号和柜号分别全局唯一,后续照片、扫码、实收、库存与完成操作只使用会话客户范围;平台管理员和客户账号不得进入该作业入口。
- 海外 PDA 柜号签收只允许平台 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 使用 `lastleg:pda:operate`;国内头程 PDA 角色不得进入。PDA 不接收 `customerId`,候选按来源柜跨客户聚合,同柜允许多个客户;选柜会话、三类照片及整柜完成事实只绑定 `sourceContainerBookingId + containerNo`,文件与柜级跨区事件使用 `tenantId=0` 平台技术范围。签收后按每个入仓计划自身客户生成任务,扫码、SKU 实收、托盘和库存始终使用入仓码所属票的真实客户。未完成会话按平台操作账号和柜号分别全局唯一;平台管理员和客户账号不得进入该作业入口。
## 查询与租户约束
@@ -10,7 +10,7 @@ import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.dto.LastlegOrderCommandDtos;
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
import com.baiyue.lastleg.entity.LastlegOrderFee;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.baiyue.lastleg.service.LastlegOrderService;
@@ -132,12 +132,14 @@ public class LastlegOrderController {
@PostMapping("/api/platform/v1/last-leg/orders/{id}/fees")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "Add a fee to one tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
public ApiResponse<LastlegOrderFee> createFee(
public ApiResponse<OverseasFeeSource> createFee(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long id,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody LastlegOrderRequests.CreateFee request) {
return ApiResponse.success(orderService.createFee(platformScope(user, customerId), id, request, operatorName(user)));
return ApiResponse.success(orderService.createFee(platformScope(user, customerId), id, request,
operatorName(user), idempotencyKey));
}
@PutMapping("/api/platform/v1/last-leg/orders/{id}/hold")
@@ -11,6 +11,7 @@ import java.util.Map;
import java.util.Collections;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.LocalDate;
public final class DeliveryRequests {
private DeliveryRequests() {}
@@ -45,8 +46,23 @@ public final class DeliveryRequests {
@NotNull @Size(min = 1, max = 500) List<@NotBlank @Size(max = 128) String> cartonMarks) {}
public record FeeItem(@NotBlank @Size(max = 64) String feeType,
@NotBlank @Size(max = 8) String currency,
@NotNull @Min(0) BigDecimal amount,
@NotBlank @Size(max = 128) String feeName,
@NotBlank @jakarta.validation.constraints.Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
@NotBlank @jakarta.validation.constraints.Pattern(regexp = "CNY|USD") String currency,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal amount,
@Min(1) Long supplierId,
@Size(max = 200) String supplierName,
@Size(max = 32) String supplierCategory,
@jakarta.validation.constraints.Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode,
LocalDateTime paidAt,
@jakarta.validation.constraints.Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod,
@Size(max = 128) String paymentReferenceNo,
@Size(max = 20) List<@NotBlank String> paymentVoucherFileIds,
@jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal chargeableQuantity,
@Size(max = 32) String quantityUnit,
@jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal unitPrice,
LocalDate periodStart,
LocalDate periodEnd,
@Size(max = 500) String remark) {}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
@@ -1,24 +0,0 @@
package com.baiyue.lastleg.delivery.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("ll_delivery_fee")
public class DeliveryFee {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String customerId;
private Long deliveryOrderId;
private String feeDirection;
private String feeType;
private String currency;
private BigDecimal amount;
private String remark;
private String createdBy;
private LocalDateTime createdAt;
}
@@ -1,16 +0,0 @@
package com.baiyue.lastleg.delivery.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.delivery.entity.DeliveryFee;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface DeliveryFeeMapper extends BaseMapper<DeliveryFee> {
@Select("SELECT * FROM ll_delivery_fee WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} ORDER BY id")
List<DeliveryFee> findByDelivery(@Param("customerId") String customerId,
@Param("deliveryOrderId") Long deliveryOrderId);
}
@@ -7,13 +7,14 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.entity.DeliveryFee;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.delivery.entity.DeliveryOperation;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy;
import com.baiyue.lastleg.delivery.mapper.DeliveryFeeMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
@@ -65,6 +66,7 @@ import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import java.math.BigDecimal;
@@ -77,7 +79,7 @@ public class DeliveryTransferService {
@Resource private OutboundOrderMapper outboundOrders;
@Resource private OutboundPalletItemMapper outboundPallets;
@Resource private DeliveryOrderMapper deliveryOrders;
@Resource private DeliveryFeeMapper deliveryFees;
@Resource private OverseasFeeSourceService feeSources;
@Resource private DeliveryOperationMapper deliveryOperations;
@Resource private OutboundPdaTaskMapper pdaTasks;
@Resource private OutboundPdaPickMapper pdaPicks;
@@ -207,9 +209,18 @@ public class DeliveryTransferService {
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
List<DeliveryRequests.FeeItem> feeInputs = request.fees() == null ? List.of() : request.fees();
for (DeliveryRequests.FeeItem input : feeInputs) {
DeliveryFee fee = new DeliveryFee(); fee.setCustomerId(customerId); fee.setDeliveryOrderId(delivery.getId());
fee.setFeeDirection("PAYABLE"); fee.setFeeType(input.feeType().trim()); fee.setCurrency(input.currency().trim().toUpperCase(Locale.ROOT));
fee.setAmount(input.amount()); fee.setRemark(trim(input.remark())); fee.setCreatedBy(operatorId); fee.setCreatedAt(now); deliveryFees.insert(fee);
feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.feeType()),
new OverseasFeeSourceDtos.SaveRequest(customerId, delivery.getCustomerName(),
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", delivery.getId(), delivery.getDeliveryNo(),
firstOrder == null ? null : firstOrder.getId(), null,
firstOrder == null ? null : firstOrder.getInboundCode(), null,
firstOrder == null ? null : parseLong(firstOrder.getSourceContainerBookingId()),
firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(delivery.getOverseasWarehouseId()),
input.feeType().trim(), input.feeName().trim(), input.direction(), input.supplierId(),
input.supplierName(), input.supplierCategory(), input.settlementMode(), input.paidAt(),
input.paymentMethod(), input.paymentReferenceNo(), input.paymentVoucherFileIds(), LocalDate.now(),
input.currency(), input.amount(), input.chargeableQuantity(), input.quantityUnit(), input.unitPrice(),
input.periodStart(), input.periodEnd()));
}
recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "CREATED", now);
bindTailOrders(customerId, items, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now);
@@ -225,8 +236,9 @@ public class DeliveryTransferService {
String feeType = input.feeType().trim().toUpperCase(Locale.ROOT);
if (!allowed.contains(feeType)) throw conflict("Unsupported delivery fee type: " + feeType);
if (!seen.add(feeType)) throw conflict("Duplicate delivery fee type: " + feeType);
if (!"USD".equalsIgnoreCase(input.currency().trim())) throw conflict("Delivery fees must use USD");
if (input.amount().signum() < 0) throw conflict("Delivery fee amount cannot be negative");
if (!Set.of("CNY", "USD").contains(input.currency().trim().toUpperCase(Locale.ROOT)))
throw conflict("Delivery fees only support CNY or USD");
if (input.amount().signum() <= 0) throw conflict("Delivery fee amount must be greater than zero");
}
if (!seen.contains("DELIVERY_FEE")) throw conflict("Delivery fee is required");
if (Set.of("FLOOR", "MIXED").contains(request.loadingType().trim().toUpperCase(Locale.ROOT))
@@ -719,7 +731,7 @@ public class DeliveryTransferService {
new LambdaQueryWrapper<LastlegOrderDetail>().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, orderIds));
List<DeliveryResponses.DeliverySku> skus = details.stream().map(detail -> sku(detail, ordersById.get(detail.getOrderId()))).toList();
return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), skus,
deliveryFees.findByDelivery(customerId, delivery.getId()).stream().map(this::feeRow).toList(),
feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(),
deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList());
}
@@ -776,7 +788,7 @@ public class DeliveryTransferService {
}
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson())); }
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); }
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason()); }
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
private LastlegOrder firstTailOrder(String customerId, List<DispatchItem> items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); }
@@ -792,6 +804,15 @@ public class DeliveryTransferService {
}
private BigDecimal totalWeight(String customerId, List<DispatchItem> items) { List<Long> ids = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); if (ids.isEmpty()) return BigDecimal.ZERO; return tailOrderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, ids)).stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static Long parseLong(String value) {
try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
catch (NumberFormatException ignored) { return null; }
}
private static String childKey(String key, String suffix) {
String prefix = key == null ? "" : key.trim();
int maxPrefix = Math.max(0, 128 - suffix.length() - 1);
return prefix.substring(0, Math.min(prefix.length(), maxPrefix)) + ':' + suffix;
}
private static String required(String value, String message) {
String normalized = trim(value);
if (normalized == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
@@ -1,7 +1,7 @@
package com.baiyue.lastleg.dto;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderFee;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import java.time.LocalDateTime;
@@ -12,7 +12,7 @@ public record LastlegOrderDetailResponse(
List<LastlegOrderCommandDtos.ManualNodeView> manualNodes,
List<LastlegOrderDetail> cargo,
List<History> statusHistory,
List<LastlegOrderFee> fees,
List<OverseasFeeSource> fees,
List<Operation> operationHistory
) {
public record History(Long id, String fromStatus, String toStatus, String remark, LocalDateTime createdAt) {}
@@ -11,6 +11,7 @@ import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.util.List;
public final class LastlegOrderRequests {
@@ -81,11 +82,25 @@ public final class LastlegOrderRequests {
}
public record CreateFee(
@NotBlank @Size(max = 128) String feeItem,
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
@NotBlank @Size(max = 64) String feeItemCode,
@NotBlank @Size(max = 200) String feeItemName,
@NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
@NotBlank @Pattern(regexp = "CNY|USD") String currency,
@NotNull @DecimalMin(value = "0.01") BigDecimal amount,
@NotBlank @Pattern(regexp = "PENDING_REVIEW|APPROVED|REJECTED") String status,
@Size(max = 500) String supplierDescription
@NotNull LocalDate businessDate,
@Min(1) Long supplierId,
@Size(max = 200) String supplierName,
@Size(max = 32) String supplierCategory,
@Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode,
LocalDateTime paidAt,
@Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod,
@Size(max = 128) String paymentReferenceNo,
@Size(max = 20) List<@NotBlank String> paymentVoucherFileIds,
@DecimalMin(value = "0.00", inclusive = false) BigDecimal chargeableQuantity,
@Size(max = 32) String quantityUnit,
@DecimalMin(value = "0.00", inclusive = false) BigDecimal unitPrice,
LocalDate periodStart,
LocalDate periodEnd
) {
}
@@ -1,25 +0,0 @@
package com.baiyue.lastleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("ll_order_fee")
public class LastlegOrderFee {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private Long orderId;
private String feeItem;
private String currency;
private BigDecimal amount;
private String status;
private String supplierDescription;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -20,8 +20,8 @@ public final class OverseasFeeSourceDtos {
public record SaveRequest(
@NotBlank @Size(max = 128) String customerId,
@Size(max = 200) String customerName,
@NotBlank @Pattern(regexp = "OVERSEAS_INBOUND_FEE|OVERSEAS_PROCUREMENT_FEE|OVERSEAS_TRUCK_FEE|OVERSEAS_LOOSEBOARD_FEE|OVERSEAS_WAREHOUSE_FEE|OVERSEAS_CONTAINER_FEE") String sourceType,
@NotBlank @Pattern(regexp = "OVERSEAS_INBOUND|OVERSEAS_PROCUREMENT|US_TRUCK|LOCAL_LOOSEBOARD|WAREHOUSE_SERVICE|OVERSEAS_CONTAINER") String businessType,
@NotBlank @Pattern(regexp = "OVERSEAS_INBOUND_FEE|OVERSEAS_PROCUREMENT_FEE|OVERSEAS_TRUCK_FEE|OVERSEAS_LOOSEBOARD_FEE|OVERSEAS_WAREHOUSE_FEE|OVERSEAS_CONTAINER_FEE|OVERSEAS_ORDER_FEE|OVERSEAS_DELIVERY_FEE|OVERSEAS_RELABEL_FEE") String sourceType,
@NotBlank @Pattern(regexp = "OVERSEAS_INBOUND|OVERSEAS_PROCUREMENT|PROCUREMENT_FEE|US_TRUCK|LOCAL_LOOSEBOARD|WAREHOUSE_SERVICE|OVERSEAS_CONTAINER|LASTLEG_ORDER|DELIVERY_ORDER|RELABEL_ORDER") String businessType,
@Min(1) Long businessId,
@NotBlank @Size(max = 128) String businessNo,
@Min(1) Long orderId,
@@ -43,8 +43,13 @@ public final class OverseasFeeSourceDtos {
@Size(max = 128) String paymentReferenceNo,
@Size(max = 20) List<@NotBlank String> paymentVoucherFileIds,
@NotNull LocalDate businessDate,
@NotBlank @Pattern(regexp = "CNY") String currency,
@NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal amount
@NotBlank @Pattern(regexp = "CNY|USD") String currency,
@NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal amount,
@DecimalMin(value = "0.00", inclusive = false) BigDecimal chargeableQuantity,
@Size(max = 32) String quantityUnit,
@DecimalMin(value = "0.00", inclusive = false) BigDecimal unitPrice,
LocalDate periodStart,
LocalDate periodEnd
) { }
public record ActionRequest(@NotBlank @Size(max = 128) String idempotencyKey) { }
@@ -46,6 +46,15 @@ public class OverseasFeeSource {
private LocalDate businessDate;
private String currency;
private BigDecimal amount;
private String originalCurrency;
private BigDecimal originalAmount;
private BigDecimal exchangeRate;
private String exchangeRateMonth;
private BigDecimal chargeableQuantity;
private String quantityUnit;
private BigDecimal unitPrice;
private LocalDate periodStart;
private LocalDate periodEnd;
private String reviewStatus;
private String publicationStatus;
private String sourceStatus;
@@ -2,7 +2,11 @@ package com.baiyue.lastleg.financefee.service;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
import feign.FeignException;
import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -14,6 +18,8 @@ import java.util.List;
@RequiredArgsConstructor
public class OverseasFeeBusinessGuard {
private final JdbcTemplate jdbc;
@Resource private DomesticWarehouseDirectoryClient warehouses;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public List<ContainerCustomerOption> listContainerCustomers(long containerBookingId) {
List<ContainerCustomerOption> customers = jdbc.query(
@@ -37,13 +43,18 @@ public class OverseasFeeBusinessGuard {
throw bad("业务记录ID不能为空");
if (fee.warehouseId() == null)
throw bad("海外仓服务费必须关联海外仓;入仓记录和入仓码按实际业务选填");
requireActiveOverseasWarehouse(fee.warehouseId());
return;
}
Table table = switch (fee.businessType()) {
case "OVERSEAS_INBOUND" -> new Table("ll_inbound_plan", "plan_no");
case "OVERSEAS_PROCUREMENT" -> new Table("ll_overseas_procurement_order", "order_no");
case "PROCUREMENT_FEE" -> new Table("ll_overseas_procurement_fee", "fee_no");
case "US_TRUCK" -> new Table("ll_us_truck_order", "order_no");
case "LOCAL_LOOSEBOARD" -> new Table("ll_local_looseboard_order", "order_no");
case "LASTLEG_ORDER" -> new Table("ll_order", "order_no");
case "DELIVERY_ORDER" -> new Table("ll_delivery_order", "delivery_no");
case "RELABEL_ORDER" -> new Table("ll_relabel_application", "relabel_no");
default -> throw bad("业务类型与业务记录ID不匹配");
};
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM " + table.name
@@ -83,6 +94,21 @@ public class OverseasFeeBusinessGuard {
if (owned == null || owned < 1) throw bad("该客户不属于费用关联的真实货柜");
}
private void requireActiveOverseasWarehouse(long warehouseId) {
try {
var response = warehouses.activeWarehouse(warehouseId, internalToken);
var warehouse = response == null || response.data() == null ? null : response.data().warehouse();
if (warehouse == null || !response.successful() || !Long.valueOf(warehouseId).equals(warehouse.id())
|| !Long.valueOf(0L).equals(warehouse.tenantId()) || !"ACTIVE".equals(warehouse.status())
|| !List.of("OVERSEAS", "OVERSEAS_PARTNER").contains(warehouse.warehouseType())) {
throw bad("海外仓不存在、已停用或类型不正确");
}
} catch (FeignException exception) {
if (exception.status() >= 400 && exception.status() < 500) throw bad("海外仓不存在、已停用或类型不正确");
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "仓库主数据暂不可用", exception);
}
}
private static boolean blank(String value) { return value == null || value.isBlank(); }
private static ResponseStatusException bad(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.financefee.service;
import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate;
import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
@Component
@RequiredArgsConstructor
public class OverseasFeeCurrencyConverter {
private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("yyyy-MM");
private final MonthlyExchangeRateMapper rates;
public Conversion convert(String currency, BigDecimal amount, LocalDate businessDate) {
String originalCurrency = currency == null ? null : currency.trim().toUpperCase();
BigDecimal originalAmount = amount == null ? null : amount.setScale(2, RoundingMode.HALF_UP);
if ("CNY".equals(originalCurrency)) {
return new Conversion(originalCurrency, originalAmount, BigDecimal.ONE.setScale(6),
businessDate.format(MONTH), originalAmount);
}
if (!"USD".equals(originalCurrency)) throw bad("海外费用原币只支持CNY或USD");
String month = businessDate.format(MONTH);
MonthlyExchangeRate rate = rates.findMonth(month);
if (rate == null || !"ACTIVE".equals(rate.getStatus()) || rate.getUsdToCnyAverage() == null
|| rate.getUsdToCnyAverage().signum() <= 0) {
throw bad("业务月份缺少有效USD兑CNY汇率,不能发布财务费用");
}
BigDecimal exchangeRate = rate.getUsdToCnyAverage().setScale(6, RoundingMode.HALF_UP);
return new Conversion(originalCurrency, originalAmount, exchangeRate, month,
originalAmount.multiply(exchangeRate).setScale(2, RoundingMode.HALF_UP));
}
private static ResponseStatusException bad(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
public record Conversion(String originalCurrency, BigDecimal originalAmount,
BigDecimal exchangeRate, String exchangeRateMonth,
BigDecimal settlementAmount) { }
}
@@ -66,7 +66,10 @@ public class OverseasFeeOutboxService {
text(value.getSupplierId()), value.getSupplierName(), value.getSupplierCategory(),
value.getSettlementMode(), instant(value.getPaidAt()), value.getPaymentMethod(),
value.getPaymentReferenceNo(), value.getPaymentVoucherFileIds() == null ? List.of() : value.getPaymentVoucherFileIds(),
value.getBusinessDate(), value.getCurrency(), value.getAmount(), value.getReviewStatus(),
value.getBusinessDate(), value.getCurrency(), value.getAmount(),
value.getOriginalCurrency(), value.getOriginalAmount(), value.getExchangeRate(), value.getExchangeRateMonth(),
value.getChargeableQuantity(), value.getQuantityUnit(), value.getUnitPrice(), value.getPeriodStart(), value.getPeriodEnd(),
value.getReviewStatus(),
"PUBLISHED", action == null && hash == null ? "ACTIVE" : value.getSourceStatus(), hash);
}
@@ -19,7 +19,6 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -40,6 +39,7 @@ public class OverseasFeeSourceService {
private final OverseasFeeCommandMapper commands;
private final OverseasFeeBusinessGuard businessGuard;
private final OverseasFeeOutboxService outbox;
private final OverseasFeeCurrencyConverter currencyConverter;
private final ObjectMapper objectMapper;
public List<OverseasFeeSource> list(String customerId, String businessType, String businessNo,
@@ -53,6 +53,15 @@ public class OverseasFeeSourceService {
.orderByDesc(OverseasFeeSource::getCreatedAt).orderByDesc(OverseasFeeSource::getId));
}
public List<OverseasFeeSource> listForBusiness(String customerId, String businessType, long businessId) {
return mapper.selectList(new LambdaQueryWrapper<OverseasFeeSource>()
.eq(OverseasFeeSource::getCustomerId, customerId)
.eq(OverseasFeeSource::getBusinessType, businessType)
.eq(OverseasFeeSource::getBusinessId, businessId)
.inSql(OverseasFeeSource::getId, "SELECT MAX(v.id) FROM ll_finance_fee_source v GROUP BY v.source_fee_id")
.orderByAsc(OverseasFeeSource::getCreatedAt).orderByAsc(OverseasFeeSource::getId));
}
public List<ContainerCustomerOption> listContainerCustomers(long containerBookingId) {
return businessGuard.listContainerCustomers(containerBookingId);
}
@@ -70,6 +79,22 @@ public class OverseasFeeSourceService {
return value;
}
@Transactional
public OverseasFeeSource publishApproved(String operatorId, String key, SaveRequest request) {
OverseasFeeSource value = create(operatorId, key, request);
if ("DRAFT".equals(value.getReviewStatus()) || "REJECTED".equals(value.getReviewStatus())) {
value = submit(value.getSourceFeeId(), operatorId, childKey(key, "submit"));
}
if ("PENDING".equals(value.getReviewStatus())) {
value = review(value.getSourceFeeId(), operatorId,
new ReviewRequest("APPROVE", "业务完成自动确认费用", childKey(key, "approve")));
}
if (!"APPROVED".equals(value.getReviewStatus()) || !"PUBLISHED".equals(value.getPublicationStatus())) {
throw conflict("业务费用未能审核发布");
}
return value;
}
@Transactional
public OverseasFeeSource submit(long sourceFeeId, String operatorId, String key) {
OverseasFeeCommand replay = replay(operatorId, "SUBMIT", key, sourceFeeId);
@@ -151,8 +176,14 @@ public class OverseasFeeSourceService {
value.setDirection(request.direction()); value.setSupplierId(request.supplierId()); value.setSupplierName(text(request.supplierName()));
value.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode()));
value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo()));
value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate()); value.setCurrency(request.currency());
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP)); value.setReviewStatus("DRAFT");
value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate());
OverseasFeeCurrencyConverter.Conversion conversion = currencyConverter.convert(request.currency(), request.amount(), request.businessDate());
value.setCurrency("CNY"); value.setAmount(conversion.settlementAmount());
value.setOriginalCurrency(conversion.originalCurrency()); value.setOriginalAmount(conversion.originalAmount());
value.setExchangeRate(conversion.exchangeRate()); value.setExchangeRateMonth(conversion.exchangeRateMonth());
value.setChargeableQuantity(request.chargeableQuantity()); value.setQuantityUnit(text(request.quantityUnit()));
value.setUnitPrice(request.unitPrice()); value.setPeriodStart(request.periodStart()); value.setPeriodEnd(request.periodEnd());
value.setReviewStatus("DRAFT");
value.setPublicationStatus("UNPUBLISHED"); value.setSourceStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setUpdatedBy(operatorId);
value.setCreatedAt(now()); value.setUpdatedAt(now());
return value;
@@ -162,16 +193,26 @@ public class OverseasFeeSourceService {
String expected = switch (value.businessType()) {
case "OVERSEAS_INBOUND" -> "OVERSEAS_INBOUND_FEE";
case "OVERSEAS_PROCUREMENT" -> "OVERSEAS_PROCUREMENT_FEE";
case "PROCUREMENT_FEE" -> "OVERSEAS_PROCUREMENT_FEE";
case "US_TRUCK" -> "OVERSEAS_TRUCK_FEE";
case "LOCAL_LOOSEBOARD" -> "OVERSEAS_LOOSEBOARD_FEE";
case "WAREHOUSE_SERVICE" -> "OVERSEAS_WAREHOUSE_FEE";
case "OVERSEAS_CONTAINER" -> "OVERSEAS_CONTAINER_FEE";
case "LASTLEG_ORDER" -> "OVERSEAS_ORDER_FEE";
case "DELIVERY_ORDER" -> "OVERSEAS_DELIVERY_FEE";
case "RELABEL_ORDER" -> "OVERSEAS_RELABEL_FEE";
default -> "";
};
if (!expected.equals(value.sourceType())) throw bad("来源类型与业务类型不匹配");
if ("OVERSEAS_CONTAINER".equals(value.businessType())
&& !OVERSEAS_CONTAINER_FEE_ITEMS.contains(value.feeItemCode().trim()))
throw bad("海外货柜费用项目不受支持");
if ((value.periodStart() == null) != (value.periodEnd() == null)
|| value.periodStart() != null && value.periodStart().isAfter(value.periodEnd()))
throw bad("计费期间必须同时填写且开始日期不能晚于结束日期");
if ((value.chargeableQuantity() == null) != (text(value.quantityUnit()) == null)
|| value.unitPrice() != null && value.chargeableQuantity() == null)
throw bad("计费数量、单位和单价信息不完整");
boolean payable = "PAYABLE".equals(value.direction());
boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty();
if (!payable) {
@@ -200,6 +241,12 @@ public class OverseasFeeSourceService {
if (existing != null && !existing.getRequestHash().equals(hash(request))) throw conflict("幂等键已被不同请求使用");
return existing;
}
private static String childKey(String key, String suffix) {
String prefix = key == null ? "" : key.trim();
int maxPrefix = Math.max(0, 128 - suffix.length() - 1);
return prefix.substring(0, Math.min(prefix.length(), maxPrefix)) + ':' + suffix;
}
private void command(String operator, String action, String key, Object request, long sourceFeeId, int version) {
OverseasFeeCommand value = new OverseasFeeCommand(); value.setId(IdWorker.getId()); value.setOperatorId(operator);
value.setAction(action); value.setIdempotencyKey(key.trim()); value.setRequestHash(hash(request)); value.setSourceFeeId(sourceFeeId);

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