Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5ac966ce9 |
@@ -2,11 +2,15 @@ package com.baiyue.common.event;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/** 跨服务业务事实使用的真实集装箱号,只规范化空白和大小写,不校验 ISO 格式。 */
|
||||
/** Canonical physical container number used by cross-region business facts. */
|
||||
public final class ContainerNumbers {
|
||||
private static final int[] LETTER_VALUES = {
|
||||
10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
|
||||
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38
|
||||
};
|
||||
|
||||
private ContainerNumbers() { }
|
||||
|
||||
/** 柜号必须非空;统一移除格式空白并转大写,保留业务录入值。 */
|
||||
public static String normalizeRequired(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("containerNo is required");
|
||||
@@ -15,13 +19,21 @@ public final class ContainerNumbers {
|
||||
value.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint)
|
||||
&& !Character.isSpaceChar(codePoint)).forEach(compact::appendCodePoint);
|
||||
String normalized = compact.toString().toUpperCase(Locale.ROOT);
|
||||
if (normalized.isEmpty()) {
|
||||
throw new IllegalArgumentException("containerNo is required");
|
||||
if (!normalized.matches("[A-Z]{3}[UJZ][0-9]{7}")) {
|
||||
throw new IllegalArgumentException("containerNo must comply with ISO 6346");
|
||||
}
|
||||
int sum = 0;
|
||||
for (int index = 0; index < 10; index++) {
|
||||
char current = normalized.charAt(index);
|
||||
int number = Character.isDigit(current) ? current - '0' : LETTER_VALUES[current - 'A'];
|
||||
sum += number * (1 << index);
|
||||
}
|
||||
if (normalized.charAt(10) - '0' != sum % 11 % 10) {
|
||||
throw new IllegalArgumentException("containerNo must comply with ISO 6346 check digit");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** 冻结事实必须已规范化,避免生产与消费端的快照或哈希发生漂移。 */
|
||||
public static String requireCanonical(String value) {
|
||||
String normalized = normalizeRequired(value);
|
||||
if (!normalized.equals(value)) {
|
||||
|
||||
+6
-27
@@ -1,41 +1,20 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.NullAndEmptySource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class ContainerNumbersTest {
|
||||
/** 规范化仍保持确定性,事件只能接受已冻结的规范值。 */
|
||||
@Test
|
||||
void normalizesWhitespaceAndCaseButCanonicalFactsRequireExactValue() {
|
||||
void normalizesWhitespaceAndCaseButCanonicalFactsRequireExactIso6346Value() {
|
||||
assertThat(ContainerNumbers.normalizeRequired(" zcsu 7881970 ")).isEqualTo("ZCSU7881970");
|
||||
assertThat(ContainerNumbers.requireCanonical("ZCSU7881970")).isEqualTo("ZCSU7881970");
|
||||
assertThatThrownBy(() -> ContainerNumbers.requireCanonical("zcsu7881970"))
|
||||
.hasMessageContaining("normalized");
|
||||
assertThatThrownBy(() -> ContainerNumbers.requireCanonical(" JZXH10231043 "))
|
||||
.hasMessageContaining("normalized");
|
||||
}
|
||||
|
||||
/** 非 ISO 柜号可以作为事件事实,不在下游重新阻断。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"JZXH10231043", "ZCSU7881971", "MSCA6639870", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
void acceptsNonIsoNumber(String containerNo) {
|
||||
assertThat(ContainerNumbers.normalizeRequired(" " + containerNo.toLowerCase(java.util.Locale.ROOT) + " "))
|
||||
.isEqualTo(containerNo);
|
||||
assertThat(ContainerNumbers.requireCanonical(containerNo)).isEqualTo(containerNo);
|
||||
}
|
||||
|
||||
/** 规范化前后均不得将空白柜号作为业务事实。 */
|
||||
@ParameterizedTest
|
||||
@NullAndEmptySource
|
||||
@ValueSource(strings = {" ", "\u00a0\u3000"})
|
||||
void rejectsMissingNumber(String containerNo) {
|
||||
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired(containerNo))
|
||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("required");
|
||||
assertThatThrownBy(() -> ContainerNumbers.requireCanonical(containerNo))
|
||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("required");
|
||||
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("ZCSU7881971"))
|
||||
.hasMessageContaining("check digit");
|
||||
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("CAB-260831-0001"))
|
||||
.hasMessageContaining("ISO 6346");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# 单测试环境数据库空库初始化
|
||||
|
||||
车辆与司机档案照片复用:先执行 `lastleg-fleet-resource-photos.sql` 新增照片版本表,再配套发布 File、Lastleg 和 Admin。车辆详情/司机详情维护照片,排车保存时复制缺失照片为当趟证据;已有照片不被后续档案替换覆盖。驾驶证统一接受 PDF/JPG/PNG。源基线与海外空库基线已同步;本次未执行环境 SQL 或部署。
|
||||
|
||||
放舱后预计到港窄维护需要配套更新 `trg_ocean_booking_before_update`:仅从冻结比较中移除 `estimated_arrival_at`,其余运输、预报、关系方及柜型冻结不变。定义已同步 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一基线;已有 TEST 需另行审阅并授权替换该触发器,不重放空库文件,不回填数据。本轮只准备定义,未执行 SQL 或部署。
|
||||
|
||||
货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。
|
||||
|
||||
仓储转运到仓取货、本土卡车接入现有排车的**可重复执行汇总SQL**为 `lastleg-dispatch-transport-tasks.sql`,文件内固定 `USE baiyue_lastleg`,对已存在字段、表安全跳过。新增 `ll_dispatch_order.task_type` 字段、`ll_dispatch_truck_task`、`ll_dispatch_transport_create`、`ll_dispatch_transport_command` 三张表,并确保既有正式排车单号日流水表存在;不回填或删除业务数据。前置是目标库已具备现有整车排车主表、FleetTrip、取货任务及本土卡车原单结构。按序先执行此 SQL,再配套发布 Lastleg 与 Admin;取消释放有效原单关联、完成保留关联。`migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql` 是迁移系统中的新增结构记录。本次仅在已授权本机执行并验收,不代表远端已部署。
|
||||
|
||||
排车单号使用`PCyyyyMMdd0001`,日期取`Asia/Shanghai`,全平台跨客户/仓库共用独立日流水。`ll_dispatch_daily_sequence`同步Lastleg源基线与海外空库基线;已有结构的定向新增入口为`lastleg-dispatch-daily-sequence.sql`,只建序列表,不改已有业务号、不回填历史、不重放空库初始化。源码和DDL须配套发布;文件存在不表示已执行或部署。流水在独立短事务分配,回滚或取消不复用,四位范围每日0001至9999,准确单量以主单统计为准。
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
-- 仅新增车辆及司机档案照片版本表,可重复执行,不回填业务数据。
|
||||
USE baiyue_lastleg;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo (
|
||||
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
|
||||
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
|
||||
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
|
||||
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
|
||||
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
|
||||
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
|
||||
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
|
||||
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
|
||||
PRIMARY KEY (resource_kind, resource_id, record_version),
|
||||
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
|
||||
@@ -1,14 +0,0 @@
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo (
|
||||
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
|
||||
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
|
||||
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
|
||||
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
|
||||
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
|
||||
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
|
||||
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
|
||||
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
|
||||
PRIMARY KEY (resource_kind, resource_id, record_version),
|
||||
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
|
||||
@@ -1484,6 +1484,7 @@ CREATE TABLE fl_container_booking (
|
||||
KEY idx_container_booking_voyage (voyage_no,updated_at),
|
||||
KEY idx_container_booking_transit_summary (actual_sailing_at,actual_warehouse_arrival_at,ocean_booking_id,container_type),
|
||||
CONSTRAINT chk_container_booking_capacity CHECK (((capacity_cbm is null) or (capacity_cbm > 0))),
|
||||
CONSTRAINT chk_container_booking_iso6346 CHECK (((container_no is null) or regexp_like(container_no,_utf8mb4'^[A-Z]{3}[UJZ][0-9]{7}$'))),
|
||||
CONSTRAINT chk_container_booking_pickup_code CHECK (((pickup_code is null) or regexp_like(pickup_code,_utf8mb4'^[0-9]{1,64}$'))),
|
||||
CONSTRAINT chk_container_booking_record_status CHECK ((record_status in (_utf8mb4'ACTIVE',_utf8mb4'MERGED'))),
|
||||
CONSTRAINT chk_container_booking_source CHECK ((source_type in (_utf8mb4'SIMULATION',_utf8mb4'OCEAN_BOOKING'))),
|
||||
@@ -6048,7 +6049,7 @@ END$$
|
||||
|
||||
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
|
||||
BEGIN
|
||||
-- 已放舱只允许预计到港、实货量及审计元数据变化,其余运输和预报快照保持冻结。
|
||||
-- 已放舱只允许实货量及其审计元数据变化,运输和预报快照保持冻结。
|
||||
IF OLD.status = 'RELEASED' AND NOT (
|
||||
(NEW.id <=> OLD.id)
|
||||
AND (NEW.booking_no <=> OLD.booking_no)
|
||||
@@ -6073,6 +6074,7 @@ BEGIN
|
||||
AND (NEW.destination_port_name <=> OLD.destination_port_name)
|
||||
AND (NEW.place_of_delivery <=> OLD.place_of_delivery)
|
||||
AND (NEW.estimated_sailing_at <=> OLD.estimated_sailing_at)
|
||||
AND (NEW.estimated_arrival_at <=> OLD.estimated_arrival_at)
|
||||
AND (NEW.remark <=> OLD.remark)
|
||||
AND (NEW.freight_term <=> OLD.freight_term)
|
||||
AND (NEW.service_term <=> OLD.service_term)
|
||||
@@ -6087,7 +6089,7 @@ BEGIN
|
||||
AND (NEW.created_at <=> OLD.created_at)
|
||||
AND NEW.version = OLD.version + 1
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱仅允许维护预计到港及补录实货量';
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱运输资料不可修改,仅允许补录实货量';
|
||||
END IF;
|
||||
IF OLD.status <> NEW.status AND NOT (
|
||||
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
|
||||
|
||||
@@ -417,7 +417,7 @@ CREATE TABLE ll_dispatch_order (
|
||||
id BIGINT NOT NULL COMMENT '整车排车主单内部主键',
|
||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号',
|
||||
task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车',
|
||||
publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否向内部散板需求池发布余位',
|
||||
publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否向内部散板需求池发布余位',
|
||||
trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID',
|
||||
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID',
|
||||
overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照',
|
||||
@@ -4987,19 +4987,4 @@ CREATE TABLE ll_document_sequence (
|
||||
CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
|
||||
|
||||
|
||||
CREATE TABLE ll_fleet_resource_photo (
|
||||
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
|
||||
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
|
||||
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
|
||||
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
|
||||
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
|
||||
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
|
||||
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
|
||||
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
|
||||
PRIMARY KEY (resource_kind, resource_id, record_version),
|
||||
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
|
||||
|
||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 235 AS initialized_tables;
|
||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 234 AS initialized_tables;
|
||||
|
||||
@@ -7,8 +7,6 @@ import java.util.Arrays;
|
||||
|
||||
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
|
||||
enum BusinessFileCategory {
|
||||
FLEET_VEHICLE_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_DRIVER_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
@@ -92,7 +90,7 @@ enum BusinessFileCategory {
|
||||
if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "公司车辆付款凭证必须归属平台");
|
||||
}
|
||||
if ((this == FLEET_VEHICLE_RECORD_PHOTO || this == FLEET_VEHICLE_PHOTO || this == FLEET_DRIVER_PHOTO) && tenantId != 0) {
|
||||
if (this == FLEET_VEHICLE_RECORD_PHOTO && tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "车辆运维照片必须归属平台");
|
||||
}
|
||||
if (this == FLEET_DRIVER_LICENSE && tenantId != 0) {
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 车辆与司机档案照片内部文件契约
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
复用InternalBusinessFileController,固定平台tenantId=0、businessType=FLEET_VEHICLE_PHOTO或FLEET_DRIVER_PHOTO、
|
||||
businessId=对应车辆或司机内部ID。Lastleg负责档案存在性、列表/维护权限、版本关联和历史可见性;
|
||||
File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。最大10MiB,仅JPG/PNG。
|
||||
上传只生成不可变对象,不改变驾驶证审核结论,不生成公开URL,排车保存时复制为本趟证据,后续档案替换不改历史排车。
|
||||
security:
|
||||
- InternalServiceToken: []
|
||||
paths:
|
||||
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
- {$ref: '#/components/parameters/BusinessType'}
|
||||
- {$ref: '#/components/parameters/BusinessId'}
|
||||
post:
|
||||
summary: 上传车辆或司机档案照片
|
||||
parameters:
|
||||
- {name: X-Operator-Id, in: header, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file: {type: string, format: binary}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Metadata'}
|
||||
'403': {description: 内部服务令牌无效}
|
||||
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
- {$ref: '#/components/parameters/BusinessType'}
|
||||
- {$ref: '#/components/parameters/BusinessId'}
|
||||
- {$ref: '#/components/parameters/FileId'}
|
||||
get:
|
||||
summary: 校验归属并读取元数据
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Metadata'}
|
||||
'403': {description: 内部服务令牌无效}
|
||||
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
- {$ref: '#/components/parameters/BusinessType'}
|
||||
- {$ref: '#/components/parameters/BusinessId'}
|
||||
- {$ref: '#/components/parameters/FileId'}
|
||||
get:
|
||||
summary: 校验归属并下载真实文件
|
||||
responses:
|
||||
'200':
|
||||
description: 文件内容,业务错误按JSON code识别
|
||||
content:
|
||||
image/jpeg: {schema: {type: string, format: binary}}
|
||||
image/png: {schema: {type: string, format: binary}}
|
||||
'403': {description: 内部服务令牌无效}
|
||||
components:
|
||||
securitySchemes:
|
||||
InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
|
||||
parameters:
|
||||
TenantId: {name: tenantId, in: path, required: true, schema: {type: string, enum: ['0']}}
|
||||
BusinessType: {name: businessType, in: path, required: true, schema: {type: string, enum: [FLEET_VEHICLE_PHOTO, FLEET_DRIVER_PHOTO]}}
|
||||
BusinessId: {name: businessId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
FileId: {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
responses:
|
||||
Metadata:
|
||||
description: 统一响应;业务成功code为200,复合归属不匹配按业务错误返回
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string, nullable: true}
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
fileId: {type: string}
|
||||
tenantId: {type: string, enum: ['0']}
|
||||
businessType: {type: string, enum: [FLEET_VEHICLE_PHOTO, FLEET_DRIVER_PHOTO]}
|
||||
businessId: {type: string}
|
||||
fileName: {type: string}
|
||||
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
contentType: {type: string, enum: [image/jpeg, image/png]}
|
||||
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760}
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class FleetResourcePhotoCategoryTest {
|
||||
@Test void profilePhotosOnlyAllowPlatformImagesAndPositiveResourceIdentities() {
|
||||
for(var category : new BusinessFileCategory[]{BusinessFileCategory.FLEET_VEHICLE_PHOTO,BusinessFileCategory.FLEET_DRIVER_PHOTO}) {
|
||||
category.validateOwner(0);
|
||||
assertThatThrownBy(() -> category.validateOwner(1)).hasMessageContaining("平台");
|
||||
assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.IMAGE);
|
||||
assertThat(category.maxSizeBytes()).isEqualTo(10L*1024*1024);
|
||||
assertThatThrownBy(() -> category.storageBusinessId("not-a-resource")).isInstanceOf(RuntimeException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@
|
||||
| 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 |
|
||||
| 柜次费用明细与跨客户分摊组 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;跨客户公共柜费使用 `allocation-groups` 按柜内真实客户和订单原子分摊,入仓 ID/入仓码同时填写或同时留空;各行金额合计必须等于原始柜费,审核、驳回、调整、作废均整组处理,单行入口拒绝分摊组;契约 `src/main/resources/openapi/container-fees-v1.yaml`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` |
|
||||
| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` |
|
||||
| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订单协作备注以 Order 为权威,`OrderRemarksSyncService` 负责订单列表与全部未冻结预装明细双向同步,完成装柜后保留冻结快照。订舱关联允许真实集装箱号暂缺;确认预装和创建 PDA task 前必须补齐非空柜号,不按 ISO 格式阻断;可在 `EDITING/LOADING` 首次补录,PDA 完成前再次校验;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 |
|
||||
| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订单协作备注以 Order 为权威,`OrderRemarksSyncService` 负责订单列表与全部未冻结预装明细双向同步,完成装柜后保留冻结快照。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 |
|
||||
| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端允许待审核、待测量/测量中、待分柜及装柜中订单,冻结每票全部有效入仓片段与满箱数;成员全部未进柜时创建未锁定组,成员已满箱进入同一装柜计划时直接创建锁定组,部分占用或跨柜时拒绝;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 |
|
||||
| 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 |
|
||||
| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 |
|
||||
@@ -58,7 +58,7 @@
|
||||
| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外仓卸柜或全部直送目的仓实际卸货完成后确认还柜;服务端按卸柜完成时间加 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 直投 Lastleg `/internal/lastleg/events`;消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java`,ACK/重试按 `../../docs/统一认证与服务通信契约.md` |
|
||||
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;前缀与生成规则见 `../../../docs/业务单据编号契约.md`,柜次及实物标签规则保持不变 |
|
||||
| 订舱级拖车与整柜出仓调度 | `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 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 |
|
||||
| 订舱级拖车与整柜出仓调度 | `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 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 |
|
||||
| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `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` |
|
||||
| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 |
|
||||
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
|
||||
@@ -79,8 +79,8 @@
|
||||
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
|
||||
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
|
||||
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
|
||||
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合但不要求订舱附件,放舱后除预计到港外的运输、预报货量、三方和柜型需求只读;预计到港允许通过 `/estimated-arrival` 按编辑权限、版本与幂等键单独维护,保留前后审计快照且不得清空或早于预计开船,不改实际到港或计划到仓;三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
|
||||
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。预计到港草稿可留空,放舱前必填且不得早于预计开船时间;关联柜次时带入柜级到港来源,供日历和默认到仓日期解析使用。
|
||||
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后运输、预报货量、三方和柜型需求只读;仅三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
|
||||
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。预计到港为可选计划,不作为放舱必填项;填写时不得早于预计开船时间。
|
||||
- 订舱号遵循 `../../../docs/业务单据编号契约.md`;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
|
||||
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
|
||||
- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。
|
||||
@@ -94,7 +94,7 @@
|
||||
|
||||
## 国内货柜履约装柜与 PDA 资源结算
|
||||
|
||||
- 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。真实集装箱号允许在关联已放舱订舱时暂缺;关联、运输修改和提柜补录统一只规范化空白和大小写,不以 ISO 长度、结构、设备类别或校验位阻断,数据库不设置 ISO 格式 CHECK,仍遵守同订舱唯一及已有柜号一致性。确认预装、创建 PDA 任务和 PDA 完成只要求真实柜号非空,不再校验 ISO 格式;跨服务装柜、船节点和卸柜事件统一接受非 ISO 柜号,冻结事实仍要求柜号已规范化且与来源柜一致。运输纠错继续遵守原因、版本及 PDA 领取和物理作业冻结限制。确认预装及创建 PDA 任务前必须补齐;历史已建但缺号的 PDA 任务可在预装详情首次补录,补录后按柜次当前真实集装箱号恢复作业。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
|
||||
- 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。真实集装箱号允许在关联已放舱订舱时暂缺,但确认预装及创建 PDA 任务前必须补齐;历史已建但缺号的 PDA 任务可在预装详情首次补录,补录后按柜次当前真实集装箱号恢复作业。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
|
||||
- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏或禁用其余未分配托盘。智能推荐必须逐个调用整托分配,容量不足时跳过整托,不得回退按箱分配;全局工作台传入当前 `customerId/inboundCode` 筛选范围时,服务端只能推荐同时命中的候选,同一次推荐按受影响客户统一重算费用,不得逐托重复重算。单行删除必须按该行 `palletId` 整托移除。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。
|
||||
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
|
||||
- PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
|
||||
|
||||
+7
-11
@@ -211,7 +211,6 @@ public class ContainerCustomsService {
|
||||
return platformView(planId);
|
||||
}
|
||||
|
||||
/** 全部预录单经客户确认后发送报关行执行,保留版本与发送状态校验。 */
|
||||
@Transactional
|
||||
public ContainerView submitToBroker(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
|
||||
String action = "SUBMIT_CUSTOMS_TO_BROKER_" + planId;
|
||||
@@ -222,8 +221,8 @@ public class ContainerCustomsService {
|
||||
ContainerRow container = mapper.lockContainer(planId);
|
||||
if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中");
|
||||
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
|
||||
if (mapper.taxRefundCount(container.loadingPlanId()) == 0) throw conflict("全买单柜无需发送预录单确认结果给报关行");
|
||||
if (mapper.unconfirmedReceiptCount(container.id()) != 0) throw conflict("仍有预录单未完成客户确认");
|
||||
if (mapper.taxRefundCount(container.loadingPlanId()) == 0) throw conflict("全买单柜无需发送客户回执给报关行");
|
||||
if (mapper.unconfirmedReceiptCount(container.id()) != 0) throw conflict("仍有报关行回执未完成客户确认");
|
||||
if (mapper.brokerSubmission(container.id()) == null) throw conflict("当前柜尚未完成首次发送报关行资料");
|
||||
if (mapper.brokerSubmittedAt(container.id()) != null) throw conflict("当前柜已发送给报关行执行");
|
||||
if (mapper.submitToBroker(container.id(), container.version(), operatorId, operatorName) != 1) throw concurrent();
|
||||
@@ -232,7 +231,6 @@ public class ContainerCustomsService {
|
||||
return platformView(planId);
|
||||
}
|
||||
|
||||
/** 上传预录单并保存文件版本,已发送报关行执行后仍禁止替换。 */
|
||||
@Transactional
|
||||
public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key,
|
||||
int expectedVersion, MultipartFile file) {
|
||||
@@ -248,7 +246,7 @@ public class ContainerCustomsService {
|
||||
throw conflict("当前客户资料不在报关中冻结版本");
|
||||
}
|
||||
if (mapper.brokerSubmittedAt(container.id()) != null) {
|
||||
throw conflict("客户确认的预录单已发送给报关行,不可再修改预录单");
|
||||
throw conflict("客户确认回执已发送给报关行,不可再修改回执");
|
||||
}
|
||||
if (mapper.brokerSubmission(container.id()) == null) {
|
||||
throw conflict("当前柜尚未完成首次发送报关行资料");
|
||||
@@ -302,7 +300,6 @@ public class ContainerCustomsService {
|
||||
return merchantViewForDocument(tenantId, source);
|
||||
}
|
||||
|
||||
/** 预录单确认并发送报关行执行后,接收真实报关完税文件。 */
|
||||
@Transactional
|
||||
public DocumentView uploadFinalDocument(long documentId, long operatorId, String operatorName, String key,
|
||||
int expectedVersion, MultipartFile file) {
|
||||
@@ -319,7 +316,7 @@ public class ContainerCustomsService {
|
||||
}
|
||||
if (mapper.brokerSubmittedAt(container.id()) == null) throw conflict("客户确认结果尚未发送给报关行");
|
||||
CompletionRow receipt = mapper.lockCompletion(documentId);
|
||||
if (receipt == null || !"CONFIRMED".equals(receipt.customerConfirmStatus())) throw conflict("该客户的预录单尚未确认");
|
||||
if (receipt == null || !"CONFIRMED".equals(receipt.customerConfirmStatus())) throw conflict("该客户回执尚未确认");
|
||||
FinalRow existing = mapper.lockFinalDocument(documentId);
|
||||
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
|
||||
long finalId;
|
||||
@@ -374,7 +371,7 @@ public class ContainerCustomsService {
|
||||
if (container == null || !(DECLARING.equals(container.status()) || COMPLETED.equals(container.status()))) throw conflict("当前柜尚未进入报关行执行阶段,不能创建正式申报批次");
|
||||
if (mapper.taxRefundCount(planId) == 0) throw conflict("全买单柜不创建退税正式申报批次");
|
||||
if (mapper.unconfirmedReceiptCount(container.id()) != 0 || mapper.brokerSubmittedAt(container.id()) == null) {
|
||||
throw conflict("当前柜尚未完成退税资料、预录单确认和报关行执行");
|
||||
throw conflict("当前柜尚未完成退税资料、回执确认和报关行执行");
|
||||
}
|
||||
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
|
||||
long batchId = IdWorker.getId();
|
||||
@@ -539,15 +536,14 @@ public class ContainerCustomsService {
|
||||
row.operatorName(), row.createdAt())).toList();
|
||||
}
|
||||
|
||||
/** 按既有客户范围与可见状态下载预录单,沿用文件真实名称。 */
|
||||
public Download downloadCompletion(long tenantId, long documentId, boolean platform) {
|
||||
DocumentRow source = requiredDocument(documentId);
|
||||
ContainerRow container = mapper.containerById(source.customsContainerId());
|
||||
if (!platform && (source.tenantId() != tenantId || container == null || !Set.of(DECLARING, COMPLETED).contains(container.status()))) {
|
||||
throw notFound("预录单不存在");
|
||||
throw notFound("报关行回执不存在");
|
||||
}
|
||||
CompletionRow completion = mapper.completion(documentId);
|
||||
if (completion == null) throw notFound("预录单不存在");
|
||||
if (completion == null) throw notFound("报关行回执不存在");
|
||||
return download(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/** ISO 6346 真实集装箱号的唯一规范化与校验入口。 */
|
||||
public final class Iso6346ContainerNumber {
|
||||
private static final int[] LETTER_VALUES = {
|
||||
10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
|
||||
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38
|
||||
};
|
||||
|
||||
private Iso6346ContainerNumber() {
|
||||
}
|
||||
|
||||
public static String require(String raw) {
|
||||
String value = normalizeWhitespace(raw);
|
||||
if (value == null || !value.matches("[A-Z]{3}[UJZ][0-9]{7}")) {
|
||||
throw invalid();
|
||||
}
|
||||
int sum = 0;
|
||||
for (int index = 0; index < 10; index++) {
|
||||
char current = value.charAt(index);
|
||||
int number = Character.isDigit(current)
|
||||
? current - '0' : LETTER_VALUES[current - 'A'];
|
||||
sum += number * (1 << index);
|
||||
}
|
||||
int expected = sum % 11 % 10;
|
||||
if (value.charAt(10) - '0' != expected) throw invalid();
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String normalizeWhitespace(String raw) {
|
||||
if (raw == null) return null;
|
||||
StringBuilder value = new StringBuilder(raw.length());
|
||||
raw.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint)
|
||||
&& !Character.isSpaceChar(codePoint))
|
||||
.forEach(value::appendCodePoint);
|
||||
String normalized = value.toString().toUpperCase(Locale.ROOT);
|
||||
return normalized.isEmpty() ? null : normalized;
|
||||
}
|
||||
|
||||
private static BizException invalid() {
|
||||
return new BizException(ResultCode.BAD_REQUEST,
|
||||
"真实集装箱号必须符合ISO 6346结构、设备类别和校验位");
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.event.ContainerNumbers;
|
||||
|
||||
/** 真实集装箱号录入与作业门禁,与跨服务事件使用同一规范化规则。 */
|
||||
public final class RealContainerNumber {
|
||||
private RealContainerNumber() { }
|
||||
|
||||
/** 保留柜号必填要求,不再检查 ISO 长度、设备类别和校验位。 */
|
||||
public static String require(String raw) {
|
||||
try {
|
||||
return ContainerNumbers.normalizeRequired(raw);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "真实集装箱号不能为空");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,7 @@ import jakarta.validation.constraints.*; import java.math.BigDecimal; import jav
|
||||
public final class TruckAppointmentDtos { private TruckAppointmentDtos(){}
|
||||
public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long oceanBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} }
|
||||
public record CandidateQuery(@Size(max=64) String keyword,@Min(1) long page,@Min(1) @Max(200) long size){public CandidateQuery{if(page==0)page=1;if(size==0)size=20;}}
|
||||
/** 已放舱订舱候选,预约不依赖订舱附件。 */
|
||||
public record Candidate(Long oceanBookingId,String oceanBookingNo,String billOfLadingNo,Long warehouseId,String warehouseName,String channelCode,String channelName){}
|
||||
public record Candidate(Long oceanBookingId,String oceanBookingNo,String billOfLadingNo,Long warehouseId,String warehouseName,String channelCode,String channelName,Boolean bookingDocumentReady){}
|
||||
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") @Digits(integer=16,fraction=2) BigDecimal payableAmount,@Pattern(regexp="RMB") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
|
||||
public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){}
|
||||
public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ }
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*;
|
||||
@Mapper public interface TruckAppointmentMapper extends BaseMapper<TruckAppointmentEntity>{
|
||||
/** 查询已放舱且有提单号的可预约订舱,不依赖附件。 */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT o.id oceanBookingId,o.booking_no oceanBookingNo,o.bill_of_lading_no billOfLadingNo,
|
||||
o.warehouse_id warehouseId,o.warehouse_name warehouseName,o.channel_code channelCode,
|
||||
o.channel_name channelName
|
||||
o.channel_name channelName,TRUE bookingDocumentReady
|
||||
FROM fl_ocean_booking o
|
||||
WHERE o.status='RELEASED' AND o.bill_of_lading_no IS NOT NULL AND o.bill_of_lading_no<>''
|
||||
WHERE EXISTS (SELECT 1 FROM fl_ocean_booking_document d WHERE d.ocean_booking_id=o.id
|
||||
AND d.valid=1 AND d.document_type IN ('BOOKING_CONFIRMATION','BILL_OF_LADING'))
|
||||
AND o.status='RELEASED' AND o.bill_of_lading_no IS NOT NULL AND o.bill_of_lading_no<>''
|
||||
AND NOT EXISTS (SELECT 1 FROM fl_truck_appointment t WHERE t.ocean_booking_id=o.id
|
||||
AND t.status NOT IN ('CANCELLED','EXCEPTION'))
|
||||
AND (#{q.keyword} IS NULL OR o.booking_no LIKE CONCAT('%',#{q.keyword},'%')
|
||||
|
||||
-11
@@ -126,17 +126,6 @@ public class PlatformOceanBookingController {
|
||||
@PutMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="编辑海运订舱草稿")
|
||||
public ApiResponse<Detail> updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); }
|
||||
/** 放舱后单独维护预计到港,其他运输资料仍冻结。 */
|
||||
@PutMapping("/{oceanBookingId}/estimated-arrival")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_ESTIMATED_ARRIVAL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="维护订舱预计到港")
|
||||
public ApiResponse<Detail> updateEstimatedArrival(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody EstimatedArrivalRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.updateEstimatedArrival(oceanBookingId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
/** 客服在放舱后补录实货量,沿用订舱编辑权限并记录审计。 */
|
||||
@PutMapping("/{oceanBookingId}/actual-cargo")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
|
||||
|
||||
@@ -95,10 +95,6 @@ public final class OceanBookingDtos {
|
||||
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
|
||||
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualVolumeCbm) {}
|
||||
|
||||
/** 放舱后仅维护预计到港,不接收其他运输资料。 */
|
||||
public record EstimatedArrivalRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotNull LocalDateTime estimatedArrivalAt) {}
|
||||
|
||||
/** 放舱后的受控实货量补录,不接收运输资料。 */
|
||||
public record ActualCargoRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@Min(1) @Max(999999999) Integer actualPackageCount,
|
||||
|
||||
@@ -27,11 +27,6 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
|
||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
@Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},freight_term=#{value.freightTerm},service_term=#{value.serviceTerm},package_unit=#{value.packageUnit},cargo_description=#{value.cargoDescription},marks_and_nos=#{value.marksAndNos},declared_package_count=#{value.declaredPackageCount},declared_gross_weight_kg=#{value.declaredGrossWeightKg},declared_volume_cbm=#{value.declaredVolumeCbm},actual_package_count=#{value.actualPackageCount},actual_gross_weight_kg=#{value.actualGrossWeightKg},actual_volume_cbm=#{value.actualVolumeCbm},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
|
||||
int updateDraft(@Param("id") long id, @Param("value") OceanBookingEntity value, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
/** 已放舱预计到港窄更新,保留状态、版本和操作审计。 */
|
||||
@Update("UPDATE fl_ocean_booking SET estimated_arrival_at=#{estimatedArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{id} AND status='RELEASED' AND version=#{version}")
|
||||
int updateEstimatedArrival(@Param("id") long id, @Param("estimatedArrivalAt") java.time.LocalDateTime estimatedArrivalAt,
|
||||
@Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
|
||||
/** 已放舱补实只更新白名单字段,保留乐观锁及状态门禁。 */
|
||||
@Update("""
|
||||
UPDATE fl_ocean_booking SET actual_package_count=#{value.actualPackageCount},
|
||||
|
||||
-5
@@ -54,11 +54,6 @@ public class OceanBookingRepository {
|
||||
public int touchDraft(long id, int version, long operatorId, String operatorName) {
|
||||
return bookings.touchDraft(id, version, operatorId, operatorName);
|
||||
}
|
||||
/** 按订舱版本仅更新已放舱预计到港,不重写其他运输或货量资料。 */
|
||||
public int updateEstimatedArrival(long id, java.time.LocalDateTime estimatedArrivalAt, int version, long operatorId, String operatorName) {
|
||||
return bookings.updateEstimatedArrival(id, estimatedArrivalAt, version, operatorId, operatorName);
|
||||
}
|
||||
|
||||
/** 只更新已放舱订舱的实际货量,运输快照保持冻结。 */
|
||||
public int updateActualCargo(OceanBookingEntity value, int version, long operatorId, String operatorName) {
|
||||
return bookings.updateActualCargo(value, version, operatorId, operatorName);
|
||||
|
||||
+4
-21
@@ -51,6 +51,7 @@ public class OceanBookingService {
|
||||
@Resource private WarehouseMasterDataClient warehouses;
|
||||
@Resource private WarehousePartnerClient partners;
|
||||
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
|
||||
@Resource private OceanBookingDocumentService documents;
|
||||
@Resource private com.baiyue.firstleg.service.IamCustomerSnapshotService customers;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
|
||||
@@ -144,24 +145,6 @@ public class OceanBookingService {
|
||||
addHistory(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.DRAFT.name(), action, null, before, after, operatorId, operatorName);
|
||||
return complete(id, operatorId, action, key, detail(after));
|
||||
}
|
||||
/** 放舱后单独维护预计到港,按版本和幂等键保存并审计前后快照。 */
|
||||
@Transactional
|
||||
public Detail updateEstimatedArrival(long id, long operatorId, String name, String key, EstimatedArrivalRequest request) {
|
||||
String action = "UPDATE_ESTIMATED_ARRIVAL";
|
||||
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
|
||||
if (replay != null) return replay;
|
||||
OceanBookingEntity before = requiredVersionForUpdate(id, request.expectedVersion());
|
||||
requireStatus(before, OceanBookingStatus.RELEASED);
|
||||
if (request.estimatedArrivalAt() == null) throw badRequest("预计到港时间不能为空");
|
||||
if (before.getEstimatedSailingAt() != null && request.estimatedArrivalAt().isBefore(before.getEstimatedSailingAt())) {
|
||||
throw badRequest("预计到港时间不能早于预计开船时间");
|
||||
}
|
||||
if (repository.updateEstimatedArrival(id, request.estimatedArrivalAt(), request.expectedVersion(), operatorId, name) != 1) throw concurrent();
|
||||
OceanBookingEntity saved = required(id);
|
||||
addHistory(id, before.getStatus(), saved.getStatus(), action, "维护预计到港", before, saved, operatorId, name);
|
||||
return complete(id, operatorId, action, key, detail(saved));
|
||||
}
|
||||
|
||||
/** 放舱后仅补录实货量,版本、幂等结果和修改前后快照在同一事务保存。 */
|
||||
@Transactional
|
||||
public Detail updateActualCargo(long id, long operatorId, String name, String key, ActualCargoRequest request) {
|
||||
@@ -193,7 +176,6 @@ public class OceanBookingService {
|
||||
complete(null, operatorId, action, key, new Detail(before, List.of(), List.of(), List.of(), List.of()));
|
||||
}
|
||||
|
||||
/** 校验完整订舱资料后放舱,不以附件上传作为前置条件。 */
|
||||
@Transactional public Detail release(long id, long operatorId, String name, String key, ReleaseRequest request) {
|
||||
String action = "RELEASE";
|
||||
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
|
||||
@@ -202,6 +184,7 @@ public class OceanBookingService {
|
||||
requireStatus(before, OceanBookingStatus.DRAFT);
|
||||
validateRelease(before, repository.parties(id), repository.requirements(id));
|
||||
validateMasterData(before);
|
||||
documents.requireActiveValidDocument(before);
|
||||
if (repository.transition(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.RELEASED.name(),
|
||||
request.expectedVersion(), operatorId, name) != 1) throw concurrent();
|
||||
OceanBookingEntity after = required(id);
|
||||
@@ -279,13 +262,13 @@ public class OceanBookingService {
|
||||
}
|
||||
}
|
||||
private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); }
|
||||
/** 放舱要求预计到港,供关联柜次的到港日历及默认到仓日期使用。 */
|
||||
/** 放舱核对必要运输资料;预计到港为可选计划,不阻断放舱。 */
|
||||
private static void validateRelease(OceanBookingEntity booking, List<OceanBookingPartyEntity> parties,
|
||||
List<OceanBookingContainerRequirementEntity> requirements) {
|
||||
if (!StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null
|
||||
|| !StringUtils.hasText(booking.getBillOfLadingNo()) || !StringUtils.hasText(booking.getVesselName())
|
||||
|| !StringUtils.hasText(booking.getVoyageNo()) || booking.getEstimatedSailingAt() == null
|
||||
|| booking.getEstimatedArrivalAt() == null || booking.getShippingCompanyId() == null
|
||||
|| booking.getShippingCompanyId() == null
|
||||
|| (!StringUtils.hasText(booking.getDeparturePortName()) && !StringUtils.hasText(booking.getDeparturePortCode()))
|
||||
|| (!StringUtils.hasText(booking.getDestinationPortName()) && !StringUtils.hasText(booking.getDestinationPortCode()))
|
||||
|| !StringUtils.hasText(booking.getPlaceOfDelivery())
|
||||
|
||||
@@ -383,12 +383,11 @@ public class FbaPricingRepository {
|
||||
.set(InboundFeeEntity::getConfirmedAt, now));
|
||||
}
|
||||
|
||||
/** 确认费用时保留物流主状态,一件代发待组托也允许确认。 */
|
||||
public int markInboundConfirmed(long tenantId, long inboundId) {
|
||||
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
||||
.in(InboundPricingEntity::getProcessStatus,
|
||||
List.of("FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"))
|
||||
List.of("FEE_CONFIRMING", "WAIT_PALLET", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"))
|
||||
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
|
||||
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
|
||||
import com.baiyue.firstleg.domain.RealContainerNumber;
|
||||
import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.repository.ContainerRepository;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
|
||||
@@ -82,19 +82,15 @@ public class ContainerService {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 首次关联已放舱订舱,仅规范化柜号并保留关联资格与并发校验。 */
|
||||
@Transactional
|
||||
public ContainerBookingEntity linkOceanBooking(long id, long operatorId, String operatorName, OceanBookingLinkRequest request) {
|
||||
ContainerBookingEntity container = required(id); requireVersion(container, request.expectedVersion()); requireFirstLinkMutable(container);
|
||||
if (container.getOceanBookingId() != null) throw conflict("模拟柜已关联真实订舱柜");
|
||||
String existingContainerNo = trim(container.getContainerNo());
|
||||
String requestedContainerNo = trim(request.containerNo());
|
||||
// 本关联入口不按 ISO 长度、结构或校验位阻断,仍统一空白与大小写以核对唯一性。
|
||||
if (requestedContainerNo != null) {
|
||||
requestedContainerNo = trim(requestedContainerNo.replaceAll("[\\p{javaWhitespace}\\p{Z}]", "")
|
||||
.toUpperCase(java.util.Locale.ROOT));
|
||||
}
|
||||
String containerNo = requestedContainerNo == null ? existingContainerNo : requestedContainerNo;
|
||||
String containerNo = requestedContainerNo == null
|
||||
? existingContainerNo
|
||||
: Iso6346ContainerNumber.require(requestedContainerNo);
|
||||
if (existingContainerNo != null && !existingContainerNo.equals(containerNo)) {
|
||||
throw conflict("输入的真实集装箱号与当前柜次已录入柜号不一致,请先在预装运输信息中纠正");
|
||||
}
|
||||
@@ -124,7 +120,6 @@ public class ContainerService {
|
||||
return required(id);
|
||||
}
|
||||
|
||||
/** 提柜后补录规范化真实柜号,保留同订舱唯一及作业冻结门禁。 */
|
||||
@Transactional
|
||||
public ContainerBookingEntity assignRealContainerNo(long id, String requestedContainerNo, int expectedVersion,
|
||||
long operatorId, String operatorName) {
|
||||
@@ -139,7 +134,7 @@ public class ContainerService {
|
||||
|| repository.hasPickTask(id) || repository.hasEvidence(id) || repository.hasDocuments(id)) {
|
||||
throw conflict("已进入PDA、证据、单证或不可逆装柜阶段,不能补录真实柜号");
|
||||
}
|
||||
String containerNo = RealContainerNumber.require(requestedContainerNo);
|
||||
String containerNo = Iso6346ContainerNumber.require(requestedContainerNo);
|
||||
if (repository.containerNoExists(container.getOceanBookingId(), containerNo, id)) {
|
||||
throw conflict("当前提单下集装箱号已存在");
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
|
||||
import com.baiyue.firstleg.repository.LoadingListRepository;
|
||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import com.baiyue.firstleg.repository.ContainerRepository;
|
||||
import com.baiyue.firstleg.domain.RealContainerNumber;
|
||||
import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
|
||||
import com.baiyue.firstleg.domain.PriceBookChannelCatalog;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
@@ -493,10 +493,10 @@ public class LoadingListService {
|
||||
return repository.preloadDetail(requirePlan(planId));
|
||||
}
|
||||
|
||||
/** 未提交柜号时保留原值,首次录入或纠错统一规范化并禁止清空。 */
|
||||
private static String targetContainerNo(AggregateRow before, String requested) {
|
||||
if (requested == null) return StringUtils.hasText(before.getContainerNo()) ? before.getContainerNo() : null;
|
||||
return RealContainerNumber.require(requested);
|
||||
if (requested != null) return Iso6346ContainerNumber.require(requested);
|
||||
return StringUtils.hasText(before.getContainerNo())
|
||||
? Iso6346ContainerNumber.require(before.getContainerNo()) : null;
|
||||
}
|
||||
|
||||
private static String transportHash(long planId, int version, String containerNo,
|
||||
|
||||
@@ -23,7 +23,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.InboundInventory;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryUnit;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.PendingTask;
|
||||
import com.baiyue.firstleg.domain.RealContainerNumber;
|
||||
import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
|
||||
import com.baiyue.firstleg.client.DomesticPdaOperatorClient;
|
||||
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
|
||||
@@ -140,7 +140,6 @@ public class LoadingPickService {
|
||||
smartLoadingRepository.items(plan.getId()));
|
||||
}
|
||||
|
||||
/** 确认预装前验证作业资格和真实柜号必填。 */
|
||||
public List<Link> requirePreloadConfirmationReady(long loadingPlanId) {
|
||||
inboundHoldGuard.requirePlanNotHeld(loadingPlanId);
|
||||
List<Link> links = loadingModelBridge.requireAllocatedLinksForConfirmation(loadingPlanId);
|
||||
@@ -152,7 +151,6 @@ public class LoadingPickService {
|
||||
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
|
||||
throw conflict("确认预装前必须录入真实集装箱号");
|
||||
}
|
||||
requireContainerNumberBeforeStarting(booking.getContainerNo());
|
||||
var allocation = oceanBookingRepository.allocationByContainer(link.containerBookingId());
|
||||
if (allocation == null) throw conflict("确认预装前必须关联真实订舱柜");
|
||||
var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId());
|
||||
@@ -166,14 +164,12 @@ public class LoadingPickService {
|
||||
return links;
|
||||
}
|
||||
|
||||
/** 仅在柜号已满足完成事件要求时创建 PDA 任务,已有任务的重复同步保持原语义。 */
|
||||
@Transactional
|
||||
public void begin(long planId, String containerNo, long operatorId, String operatorName,
|
||||
List<LoadingPlanItemEntity> planItems) {
|
||||
inboundHoldGuard.requirePlanNotHeld(planId);
|
||||
LoadingPickTaskEntity task = repository.taskByPlan(planId);
|
||||
if (task == null) {
|
||||
requireContainerNumberBeforeStarting(containerNo);
|
||||
task = new LoadingPickTaskEntity();
|
||||
task.setLoadingPlanId(planId);
|
||||
var booking = smartLoadingRepository.booking(smartLoadingRepository.plan(planId).getContainerBookingId());
|
||||
@@ -614,25 +610,15 @@ public class LoadingPickService {
|
||||
/** 本进程事务提交后的唤醒信号,权威命令和恢复事实仍保存在数据库。 */
|
||||
public record CompletionAccepted(long commandId, long acceptedAtNanos) {}
|
||||
|
||||
/** 确认预装和创建 PDA 任务前要求柜号非空,不以 ISO 格式阻断作业。 */
|
||||
private static void requireContainerNumberBeforeStarting(String containerNo) {
|
||||
try {
|
||||
RealContainerNumber.require(containerNo);
|
||||
} catch (BizException exception) {
|
||||
throw conflict("请先在预装运输信息中录入真实集装箱号后再确认预装或启动PDA");
|
||||
}
|
||||
}
|
||||
|
||||
/** 完成前复核真实柜号,以生成一致的跨服务装柜完成事件。 */
|
||||
private void requireCompletionContainerNumber(long taskId) {
|
||||
ContainerBookingEntity booking = repository.activeBookingByTask(taskId);
|
||||
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
|
||||
throw conflict("PDA确认装柜完成前必须录入真实集装箱号");
|
||||
}
|
||||
try {
|
||||
RealContainerNumber.require(booking.getContainerNo());
|
||||
Iso6346ContainerNumber.require(booking.getContainerNo());
|
||||
} catch (BizException exception) {
|
||||
throw conflict("PDA确认装柜完成前必须录入真实集装箱号");
|
||||
throw conflict("PDA确认装柜完成前必须录入符合ISO 6346的真实集装箱号");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.baiyue.firstleg.dto.TruckAppointmentDtos.*;
|
||||
import com.baiyue.firstleg.entity.*;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
import com.baiyue.firstleg.oceanbooking.service.OceanBookingDocumentService;
|
||||
import com.baiyue.firstleg.repository.TruckAppointmentRepository;
|
||||
import com.baiyue.firstleg.client.TruckAppointmentFileClient;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -35,6 +36,7 @@ public class TruckAppointmentService {
|
||||
@Resource TruckAppointmentRepository repo;
|
||||
@Resource TruckAppointmentCompletionService completion;
|
||||
@Resource OceanBookingRepository oceanBookings;
|
||||
@Resource OceanBookingDocumentService oceanBookingDocuments;
|
||||
@Resource TruckAppointmentFileClient files;
|
||||
@Resource WarehouseSupplierSnapshotService suppliers;
|
||||
@Resource FirstlegDocumentNumberGenerator documentNumbers;
|
||||
@@ -62,13 +64,13 @@ public class TruckAppointmentService {
|
||||
return completeOperation(id,action,idem,enrich(required(id)));
|
||||
}
|
||||
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);completion.lockBooking(e.getOceanBookingId());checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); TruckAppointmentEntity active=repo.firstActiveForUpdate(e.getOceanBookingId());if(active!=null&&!Objects.equals(active.getId(),e.getId()))throw existingAppointmentConflict(active);String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=enrich(required(id));record(after,from,"RESCHEDULED",action,r.reason(),uid,name);completion.completeForBooking(e.getOceanBookingId());return completeOperation(id,action,idem,enrich(required(id)));}
|
||||
/** 从已放舱订舱校验仓库和提单号,附件不作为预约条件。 */
|
||||
private AssociationContext validateAssociations(SaveRequest r){
|
||||
OceanBookingEntity booking=oceanBookings.find(r.oceanBookingId());
|
||||
if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");
|
||||
if(!"RELEASED".equals(booking.getStatus()))throw conflict("拖车预约只能选择已放舱订舱");
|
||||
Long warehouseId=mergeWarehouse(r.warehouseId(),booking.getWarehouseId(),"订舱");
|
||||
String billOfLadingNo=releasedBookingBill(booking,trim(r.billOfLadingNo()));
|
||||
oceanBookingDocuments.requireActiveValidDocument(booking);
|
||||
return new AssociationContext(booking,warehouseId,billOfLadingNo);
|
||||
}
|
||||
private static void validateRequired(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");if(r.payableAmount()!=null&&(r.payableAmount().signum()<0||r.payableAmount().scale()>2))throw bad("拖车费必须为非负数且最多两位小数");}
|
||||
@@ -83,13 +85,13 @@ public class TruckAppointmentService {
|
||||
private static void verifyClientHash(String clientHash,String serverHash){if(StringUtils.hasText(clientHash)&&!serverHash.equalsIgnoreCase(clientHash.trim()))throw bad("X-Request-Hash与请求内容不一致");}
|
||||
private static void validateKey(String key){if(!StringUtils.hasText(key)||key.trim().length()>128)throw bad("Idempotency-Key不能为空且不能超过128个字符");}
|
||||
private record ReschedulePayload(LocalDateTime plannedLoadingAt,ActionRequest request){}
|
||||
/** 派车时复核订舱放舱状态及提单号,不要求订舱附件。 */
|
||||
private String requireDispatchReady(TruckAppointmentEntity appointment){
|
||||
if(appointment.getOceanBookingId()==null)throw conflict("拖车预约缺少订舱关联");
|
||||
OceanBookingEntity booking=oceanBookings.find(appointment.getOceanBookingId());
|
||||
if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");
|
||||
if(!"RELEASED".equals(booking.getStatus()))throw conflict("关联订舱尚未放舱");
|
||||
String bill=releasedBookingBill(booking,trim(appointment.getBillOfLadingNo()));
|
||||
oceanBookingDocuments.requireActiveValidDocument(booking);
|
||||
return bill;
|
||||
}
|
||||
private static String releasedBookingBill(OceanBookingEntity booking,String requested){String authoritative=trim(booking.getBillOfLadingNo());if(!StringUtils.hasText(authoritative))throw conflict("关联已放舱订舱缺少提单号");if(StringUtils.hasText(requested)&&!authoritative.equals(requested))throw bad("billOfLadingNo与关联订舱不一致");return authoritative;}
|
||||
|
||||
+4
-6
@@ -9,12 +9,11 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ContainerCalendarSourceMapper {
|
||||
/** 按明确来源柜读取到港事实;目的港优先显示名称,未填名称时显示已保存代码。 */
|
||||
/** 按明确来源柜读取与预约日历相同的到港事实,实际到港优先。 */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
||||
c.vessel_name vesselName,c.voyage_no voyageNo,
|
||||
COALESCE(NULLIF(TRIM(c.destination_port_name),''),NULLIF(TRIM(c.destination_port_code),'')) destinationPortName,
|
||||
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
|
||||
c.destination_warehouse_code destinationWarehouseCode,
|
||||
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
|
||||
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
|
||||
@@ -29,11 +28,10 @@ public interface ContainerCalendarSourceMapper {
|
||||
""")
|
||||
List<ContainerCalendarSource> byIds(@Param("ids") List<Long> ids);
|
||||
|
||||
/** 选出到港、缺日期和可能还柜的有效出仓柜;目的港名称为空时展示代码,最终日期由服务复核。 */
|
||||
/** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */
|
||||
@Select("""
|
||||
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
||||
c.vessel_name vesselName,c.voyage_no voyageNo,
|
||||
COALESCE(NULLIF(TRIM(c.destination_port_name),''),NULLIF(TRIM(c.destination_port_code),'')) destinationPortName,
|
||||
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
|
||||
c.destination_warehouse_code destinationWarehouseCode,
|
||||
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
|
||||
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
|
||||
|
||||
@@ -232,7 +232,7 @@ DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
|
||||
BEGIN
|
||||
-- 已放舱只允许预计到港、实货量及审计元数据变化,其余运输和预报快照保持冻结。
|
||||
-- 已放舱只允许实货量及其审计元数据变化,运输和预报快照保持冻结。
|
||||
IF OLD.status = 'RELEASED' AND NOT (
|
||||
(NEW.id <=> OLD.id)
|
||||
AND (NEW.booking_no <=> OLD.booking_no)
|
||||
@@ -257,6 +257,7 @@ BEGIN
|
||||
AND (NEW.destination_port_name <=> OLD.destination_port_name)
|
||||
AND (NEW.place_of_delivery <=> OLD.place_of_delivery)
|
||||
AND (NEW.estimated_sailing_at <=> OLD.estimated_sailing_at)
|
||||
AND (NEW.estimated_arrival_at <=> OLD.estimated_arrival_at)
|
||||
AND (NEW.remark <=> OLD.remark)
|
||||
AND (NEW.freight_term <=> OLD.freight_term)
|
||||
AND (NEW.service_term <=> OLD.service_term)
|
||||
@@ -271,7 +272,7 @@ BEGIN
|
||||
AND (NEW.created_at <=> OLD.created_at)
|
||||
AND NEW.version = OLD.version + 1
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱仅允许维护预计到港及补录实货量';
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱运输资料不可修改,仅允许补录实货量';
|
||||
END IF;
|
||||
IF OLD.status <> NEW.status AND NOT (
|
||||
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
|
||||
|
||||
@@ -35,7 +35,7 @@ CREATE TABLE IF NOT EXISTS fl_document_sequence (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
|
||||
container_no VARCHAR(64) NULL COMMENT '国内柜次真实集装箱号权威快照;订舱关联不限制ISO格式;同一订舱内唯一,不同提单允许复用;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
|
||||
container_no VARCHAR(64) NULL COMMENT '国内柜次真实集装箱号权威快照;ISO 6346标准格式;同一订舱内唯一,不同提单允许复用;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
|
||||
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
|
||||
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
|
||||
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
|
||||
@@ -100,7 +100,9 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')),
|
||||
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
|
||||
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0),
|
||||
CONSTRAINT chk_container_booking_pickup_code CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$')
|
||||
CONSTRAINT chk_container_booking_pickup_code CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$'),
|
||||
CONSTRAINT chk_container_booking_iso6346 CHECK
|
||||
(container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$')
|
||||
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_container_event_inbox (
|
||||
@@ -226,6 +228,11 @@ BEGIN
|
||||
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_record_status
|
||||
CHECK (record_status IN ('ACTIVE','MERGED'));
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_iso6346') THEN
|
||||
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_iso6346
|
||||
CHECK (container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_pickup_code') THEN
|
||||
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_pickup_code
|
||||
|
||||
@@ -40,7 +40,7 @@ paths:
|
||||
containerNo: {type: string, nullable: true}
|
||||
vesselName: {type: string, nullable: true}
|
||||
voyageNo: {type: string, nullable: true}
|
||||
destinationPortName: {type: string, nullable: true, description: 目的港展示值,优先名称,名称为空时使用已保存的港口代码,两者均为空时返回null}
|
||||
destinationPortName: {type: string, nullable: true}
|
||||
destinationWarehouseCode: {type: string, nullable: true}
|
||||
arrivalAt: {type: string, format: date-time, nullable: true}
|
||||
arrivalType: {type: string, enum: [ACTUAL, ESTIMATED]}
|
||||
|
||||
@@ -118,9 +118,9 @@ paths:
|
||||
x-permission: platform:firstleg:customs:list
|
||||
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
||||
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
||||
/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/download:
|
||||
/api/platform/v1/customs/loading-plans/documents/{documentId}/receipt/download:
|
||||
get:
|
||||
summary: 平台下载预录单
|
||||
summary: 平台下载报关行回执
|
||||
x-permission: platform:firstleg:customs:list
|
||||
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
||||
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
||||
|
||||
@@ -607,7 +607,7 @@ paths:
|
||||
/api/platform/v1/containers/{id}/ocean-booking-link:
|
||||
post:
|
||||
summary: 将内部模拟柜关联到已发布订舱需求
|
||||
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId 和未满配 requirementId,containerNo 可空。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;提柜后由拖车链调用柜次服务原子补录真实柜号。若关联请求已提供 containerNo,服务端仅移除空白并转大写,不校验 ISO 6346 长度、结构或校验位,并保证同一订舱内唯一;不同提单允许复用同一集装箱号。运输信息、提柜补录、确认预装、创建PDA任务、装柜完成及跨服务事件统一不校验ISO格式;确认预装及创建PDA任务前仍必须补齐真实柜号。存在跨仓、跨渠道、柜型规格不一致、超量、同提单重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
|
||||
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId 和未满配 requirementId,containerNo 可空。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;提柜后由拖车链调用柜次服务原子补录真实柜号。若关联请求已提供 containerNo,服务端仍规范化并校验 ISO 6346 结构、设备类别和校验位,并保证同一订舱内唯一;不同提单允许复用同一集装箱号。存在跨仓、跨渠道、柜型规格不一致、超量、同提单重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
|
||||
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
|
||||
@@ -2947,7 +2947,7 @@ components:
|
||||
properties:
|
||||
oceanBookingId: {type: integer, format: int64, minimum: 1, description: RELEASED订舱ID}
|
||||
requirementId: {type: integer, format: int64, minimum: 1, description: 未满配的订舱柜型需求ID}
|
||||
containerNo: {type: string, nullable: true, maxLength: 64, description: 可选;港口提柜后可由拖车链另行原子补录。关联时移除格式空白并转大写,不校验ISO长度、结构或校验位,仍保证同一订舱内唯一;确认预装及创建PDA任务前必须补齐真实柜号,不校验ISO格式}
|
||||
containerNo: {type: string, nullable: true, maxLength: 64, description: 可选;港口提柜后可由拖车链另行原子补录。非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别、校验位和同一订舱内唯一性}
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
ContainerFeeAdjustRequest:
|
||||
type: object
|
||||
|
||||
@@ -50,7 +50,7 @@ paths:
|
||||
summary: 修改预装运输信息
|
||||
description: >-
|
||||
可在 EDITING 或 LOADING 阶段首次补录真实集装箱号;已有柜号的纠正仅限仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时,并必须提供原因和当前版本;
|
||||
containerNo 为 null 时保留当前柜号;首次录入或纠正时服务端移除格式空白并转大写,不校验 ISO 6346 长度、结构、设备类别或校验位,空白字符串非法且禁止清空;同一订舱内柜号必须唯一。
|
||||
containerNo 为 null 表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。
|
||||
x-permission: platform:firstleg:preload:edit
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/PlanId'}
|
||||
|
||||
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: Ocean Booking API
|
||||
version: 2.2.0
|
||||
description: Booking-owned files use File businessType OCEAN_BOOKING_DOCUMENT and businessId=oceanBookingId. 放舱不要求上传订舱确认文件或提单文件。 Released transport and declared cargo snapshots and file links are read-only. Manual actual cargo may be supplemented with version, idempotency and audit reason.
|
||||
description: Booking-owned files use File businessType OCEAN_BOOKING_DOCUMENT and businessId=oceanBookingId. Release requires at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Released transport and declared cargo snapshots and file links are read-only. Manual actual cargo may be supplemented with version, idempotency and audit reason.
|
||||
paths:
|
||||
/api/platform/v1/ocean-bookings:
|
||||
get:
|
||||
@@ -29,14 +29,6 @@ paths:
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
|
||||
responses: {'200': {description: Deleted}}
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/estimated-arrival:
|
||||
put:
|
||||
summary: 放舱后维护预计到港
|
||||
description: 仅允许RELEASED订舱按当前版本与幂等键维护estimatedArrivalAt,不得清空或早于预计开船时间;其他运输、预报、关系方、柜型与附件仍冻结,保存前后快照及操作人。不修改实际到港或计划到仓。
|
||||
x-permission: platform:firstleg:ocean-booking:edit
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/EstimatedArrivalRequest'}}}}
|
||||
responses: {'200': {description: 已保存的RELEASED订舱详情,包含当前版本和预计到港}}
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/actual-cargo:
|
||||
put:
|
||||
summary: Supplement manual actual cargo on a RELEASED booking
|
||||
@@ -48,7 +40,7 @@ paths:
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/release:
|
||||
post:
|
||||
summary: Validate and permanently release a DRAFT
|
||||
description: 放舱校验已保存的订舱业务资料,不以订舱确认文件或提单文件作为前置条件。 The customer is optional; a supplied customer must remain ACTIVE. Null denotes no single customer snapshot, never public access. It never requires or creates a real container number.
|
||||
description: Release validates the persisted aggregate plus at least one active booking-owned BOOKING_CONFIRMATION or BILL_OF_LADING file. The customer is optional; a supplied customer must remain ACTIVE. Null denotes no single customer snapshot, never public access. It never requires or creates a real container number.
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}}
|
||||
responses: {'200': {description: RELEASED detail}}
|
||||
@@ -208,7 +200,7 @@ components:
|
||||
destinationPortName: {type: string, nullable: true}
|
||||
placeOfDelivery: {type: string, nullable: true}
|
||||
estimatedSailingAt: {type: string, format: date-time, nullable: true}
|
||||
estimatedArrivalAt: {type: string, format: date-time, nullable: true, description: 草稿可留空,放舱前必填;不得早于预计开船时间,关联柜次后作为到港日历及默认到仓日期来源}
|
||||
estimatedArrivalAt: {type: string, format: date-time, nullable: true, description: 可选预计到港时间;放舱不要求填写,填写时不得早于预计开船时间}
|
||||
remark: {type: string, nullable: true}
|
||||
freightTerm: {type: string, maxLength: 64, nullable: true, description: 运费条款;新建页面默认FREIGHT PREPAID}
|
||||
serviceTerm: {type: string, maxLength: 64, nullable: true, description: 运输条款;新建页面默认CY-CY}
|
||||
@@ -225,13 +217,6 @@ components:
|
||||
containerRequirements: {type: array, nullable: true, items: {$ref: '#/components/schemas/ContainerRequirementRequest'}}
|
||||
PartyRequest: {type: object, required: [partyType, partyName], properties: {partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}, partyName: {type: string}, address: {type: string, nullable: true}, contactName: {type: string, nullable: true}, contactPhone: {type: string, nullable: true}, contactEmail: {type: string, format: email, maxLength: 255, nullable: true}, taxNo: {type: string, maxLength: 128, nullable: true}}}
|
||||
ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 统一实装上限;40GP=58、40HQ=68、45HQ=86}, maxWeightKg: {type: number, enum: [19500], description: 统一货重上限}}}
|
||||
EstimatedArrivalRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [expectedVersion, estimatedArrivalAt]
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
estimatedArrivalAt: {type: string, format: date-time}
|
||||
ActualCargoRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
|
||||
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: Truck Appointment API
|
||||
version: 2.0.0
|
||||
description: A truck appointment belongs to one RELEASED ocean booking, independently of when or how that booking's internal containers are linked. Each booking may have at most one non-terminal appointment. The bill-of-lading number and warehouse are frozen from the booking. 预约创建及派车不要求订舱附件。 Container identities and real container numbers remain in the container/loading modules and are not part of this API. PDA loading completion alone does not complete the appointment. All required containers must be allocated and confirmed departed from the domestic warehouse. Completion atomically creates approved ON_ACCOUNT container payable fees allocated by loaded volume and publishes their immutable snapshots through the existing Finance Outbox. Create, edit, submit and reschedule also recheck departure facts, covering late appointments. Zero amounts create no fee. Cancelled and exception appointments are excluded. RMB is the only appointment currency and maps to CNY in Finance.
|
||||
description: A truck appointment belongs to one RELEASED ocean booking, independently of when or how that booking's internal containers are linked. Each booking may have at most one non-terminal appointment. The bill-of-lading number and warehouse are frozen from the booking, which must own at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Container identities and real container numbers remain in the container/loading modules and are not part of this API. PDA loading completion alone does not complete the appointment. All required containers must be allocated and confirmed departed from the domestic warehouse. Completion atomically creates approved ON_ACCOUNT container payable fees allocated by loaded volume and publishes their immutable snapshots through the existing Finance Outbox. Create, edit, submit and reschedule also recheck departure facts, covering late appointments. Zero amounts create no fee. Cancelled and exception appointments are excluded. RMB is the only appointment currency and maps to CNY in Finance.
|
||||
paths:
|
||||
/api/platform/v1/truck-appointments:
|
||||
get:
|
||||
@@ -33,7 +33,7 @@ paths:
|
||||
/api/platform/v1/truck-appointments/candidates:
|
||||
get:
|
||||
summary: Query authoritative create-time ocean-booking candidates
|
||||
description: Requires platform:firstleg:truck-appointment:list. Returns one row per RELEASED booking with a bill of lading number, excluding bookings that already have an active or completed truck appointment. Container allocation and loading status do not affect eligibility. Edit pages display their current booking from appointment detail rather than injecting it into this create-only result.
|
||||
description: Requires platform:firstleg:truck-appointment:list. Returns one row per RELEASED booking with a bill of lading and an active booking-owned confirmation or bill file, excluding bookings that already have an active or completed truck appointment. Container allocation and loading status do not affect eligibility. Edit pages display their current booking from appointment detail rather than injecting it into this create-only result.
|
||||
parameters:
|
||||
- {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}, description: Matches booking number or bill of lading}
|
||||
- {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}}
|
||||
@@ -168,7 +168,7 @@ components:
|
||||
timestamp: {type: string, format: date-time}
|
||||
Candidate:
|
||||
type: object
|
||||
required: [oceanBookingId, oceanBookingNo, warehouseId]
|
||||
required: [oceanBookingId, oceanBookingNo, warehouseId, bookingDocumentReady]
|
||||
properties:
|
||||
oceanBookingId: {type: integer, format: int64}
|
||||
oceanBookingNo: {type: string}
|
||||
@@ -177,7 +177,7 @@ components:
|
||||
warehouseName: {type: string, nullable: true}
|
||||
channelCode: {type: string, nullable: true}
|
||||
channelName: {type: string, nullable: true}
|
||||
|
||||
bookingDocumentReady: {type: boolean, enum: [true]}
|
||||
DetailEnvelope:
|
||||
type: object
|
||||
required: [code, message, data, timestamp]
|
||||
@@ -221,7 +221,7 @@ components:
|
||||
required: [oceanBookingId, carrierId, plannedLoadingAt, expectedVersion]
|
||||
properties:
|
||||
outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true}
|
||||
oceanBookingId: {type: integer, format: int64, minimum: 1, description: Required RELEASED booking; must have a bill of lading number. 订舱附件不作为前置条件。}
|
||||
oceanBookingId: {type: integer, format: int64, minimum: 1, description: Required RELEASED booking; must have a bill of lading and own an active BOOKING_CONFIRMATION or BILL_OF_LADING file.}
|
||||
warehouseId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional consistency hint. The persisted warehouse is frozen from the selected booking.}
|
||||
carrierId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE Warehouse supplier ID validated as TRUCKING with truckingScope DOMESTIC (国内拖车).}
|
||||
carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse supplier master data.}
|
||||
|
||||
+2
-3
@@ -88,7 +88,7 @@ class ContainerControllerContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("OpenAPI声明平台级货柜路径、关联柜号格式放行及冻结规则")
|
||||
@DisplayName("OpenAPI声明平台级货柜路径和冻结规则")
|
||||
void documentsContainerContract() throws Exception {
|
||||
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
@@ -106,8 +106,7 @@ class ContainerControllerContractTest {
|
||||
.contains("未满配 requirementId")
|
||||
.contains("containerNo 可空")
|
||||
.contains("提柜后由拖车链调用柜次服务原子补录真实柜号")
|
||||
.contains("不校验 ISO 6346 长度、结构或校验位,并保证同一订舱内唯一")
|
||||
.contains("运输信息、提柜补录、确认预装、创建PDA任务、装柜完成及跨服务事件统一不校验ISO格式")
|
||||
.contains("校验 ISO 6346 结构、设备类别和校验位,并保证同一订舱内唯一")
|
||||
.doesNotContain("/api/platform/v1/containers/{id}/transport:")
|
||||
.doesNotContain("ContainerOceanBookingUnlinkRequest")
|
||||
.doesNotContain("ContainerTransportUpdateRequest")
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ class LoadingListControllerContractTest {
|
||||
.contains("containerNo")
|
||||
.contains("voyageNo")
|
||||
.contains("首次补录真实集装箱号")
|
||||
.contains("不校验 ISO 6346 长度、结构、设备类别或校验位")
|
||||
.contains("校验ISO 6346结构、设备类别和校验位")
|
||||
.contains("PDA任务尚未领取且无任何物理作业事实")
|
||||
.contains("/loading-plans/{planId}/confirm")
|
||||
.contains("/loading-plans/{planId}/lifecycle")
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ class TruckAppointmentControllerContractTest {
|
||||
}
|
||||
@Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");}
|
||||
@Test void openApiDocumentsActionableDuplicateCreateConflict() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("A0409","existing appointment number","continue editing","database booking-level unique key");}
|
||||
@Test void openApiPublishesBookingLevelCandidateContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/candidates:","Paged booking candidates","Container allocation and loading status do not affect eligibility").doesNotContain("requirementId:","containerBookingId:","loadingPlanId:");}
|
||||
@Test void openApiPublishesBookingLevelCandidateContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/candidates:","Paged booking candidates","bookingDocumentReady","Container allocation and loading status do not affect eligibility").doesNotContain("requirementId:","containerBookingId:","loadingPlanId:");}
|
||||
@Test void openApiRequiresReleasedBookingWithoutContainerWriteProtocol() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","PICKUP does not accept or write a container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}").doesNotContain("containerBookingId:","loadingPlanId:","containerNo:","customerId", "customerName");}
|
||||
@Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");}
|
||||
@Test void openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB]","default: RMB").doesNotContain("receivableAmount");}
|
||||
|
||||
-17
@@ -4,7 +4,6 @@ import com.baiyue.firstleg.customs.container.controller.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
@@ -13,22 +12,6 @@ import java.lang.reflect.Method;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ContainerCustomsControllerContractTest {
|
||||
/** 下载契约必须跟随实际 Controller,避免前端和文档共同固化不存在的地址。 */
|
||||
@Test void platformPreEntryDownloadMatchesActualControllerAndOpenApi() throws Exception {
|
||||
Method download = java.util.Arrays.stream(PlatformContainerCustomsController.class.getDeclaredMethods())
|
||||
.filter(method -> method.getName().equals("downloadCompletion")).findFirst().orElseThrow();
|
||||
String root = PlatformContainerCustomsController.class.getAnnotation(RequestMapping.class).value()[0];
|
||||
String path = root + download.getAnnotation(GetMapping.class).value()[0];
|
||||
String source = new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
|
||||
java.util.Map<?, ?> specification = new Yaml().load(source);
|
||||
java.util.Map<?, ?> paths = (java.util.Map<?, ?>) specification.get("paths");
|
||||
|
||||
assertThat(path).isEqualTo("/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/download");
|
||||
assertThat(paths.containsKey(path)).isTrue();
|
||||
assertThat(paths.containsKey(root + "/documents/{documentId}/receipt/download")).isFalse();
|
||||
assertThat(download.getAnnotation(PreAuthorize.class).value()).contains("platform:firstleg:customs:list");
|
||||
}
|
||||
|
||||
@Test void platformUsesListForReadsAndManageForWrites() {
|
||||
assertThat(PlatformContainerCustomsController.class.getAnnotation(RequestMapping.class).value())
|
||||
.containsExactly("/api/platform/v1/customs/loading-plans");
|
||||
|
||||
+2
-38
@@ -442,7 +442,6 @@ class ContainerCustomsServiceTest {
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
/** 上传被拒绝时使用预录单名称,并保留报关行执行后的替换门禁。 */
|
||||
@Test void receiptCannotBeReplacedAfterBrokerSubmission(){
|
||||
var source=document(1L);when(mapper.document(1L)).thenReturn(source);
|
||||
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("broker-sent-key"),anyString())).thenReturn(1);
|
||||
@@ -451,10 +450,7 @@ class ContainerCustomsServiceTest {
|
||||
when(mapper.brokerSubmittedAt(2L)).thenReturn(java.time.LocalDateTime.now());
|
||||
var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1});
|
||||
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","broker-sent-key",0,file))
|
||||
.isInstanceOfSatisfying(BizException.class, error -> {
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
assertThat(error).hasMessage("客户确认的预录单已发送给报关行,不可再修改预录单");
|
||||
});
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不可再修改回执");
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
@@ -516,43 +512,11 @@ class ContainerCustomsServiceTest {
|
||||
verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString());
|
||||
}
|
||||
|
||||
/** 下载不可见或缺失的预录单时使用正式名称,不改变客户范围校验。 */
|
||||
@Test void merchantCannotDownloadCompletionBeforeWholeContainerCompletes(){
|
||||
var source=document(1L);when(mapper.document(1L)).thenReturn(source);
|
||||
when(mapper.containerById(2L)).thenReturn(container(ContainerCustomsService.DECLARING,3));
|
||||
assertThatThrownBy(()->service.downloadCompletion(7L,1L,false)).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("预录单不存在");
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
/** 平台下载的当前预录单不存在时仍返回原业务错误码和正式名称。 */
|
||||
@Test void platformMissingPreEntryDocumentUsesCurrentName() {
|
||||
when(mapper.document(1L)).thenReturn(document(1L));
|
||||
when(mapper.completion(1L)).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.downloadCompletion(0L, 1L, true))
|
||||
.isInstanceOfSatisfying(BizException.class, error -> {
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND);
|
||||
assertThat(error).hasMessage("预录单不存在");
|
||||
});
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
/** 发送前置校验仅调整用户可见名称,不改变全买单或客户确认门禁。 */
|
||||
@ParameterizedTest
|
||||
@CsvSource({"0,0,全买单柜无需发送预录单确认结果给报关行", "1,1,仍有预录单未完成客户确认"})
|
||||
void brokerSubmissionErrorsUsePreEntryDocumentName(int taxRefundCount, int unconfirmedCount, String message) {
|
||||
when(mapper.claimCommand(anyLong(), eq(5L), eq("SUBMIT_CUSTOMS_TO_BROKER_9"), eq("pre-entry-name"), anyString())).thenReturn(1);
|
||||
when(mapper.lockContainer(9L)).thenReturn(container(ContainerCustomsService.DECLARING, 3));
|
||||
when(mapper.taxRefundCount(9L)).thenReturn(taxRefundCount);
|
||||
when(mapper.unconfirmedReceiptCount(2L)).thenReturn(unconfirmedCount);
|
||||
|
||||
assertThatThrownBy(() -> service.submitToBroker(9L, 5L, "admin", "pre-entry-name", new ActionRequest(3, true, null)))
|
||||
.isInstanceOfSatisfying(BizException.class, error -> {
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
assertThat(error).hasMessage(message);
|
||||
});
|
||||
verify(mapper, never()).submitToBroker(anyLong(), anyInt(), anyLong(), anyString());
|
||||
.hasMessageContaining("报关行回执不存在");
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class Iso6346ContainerNumberTest {
|
||||
@Test
|
||||
void normalizesAllowedWhitespaceAndLowercase() {
|
||||
assertThat(Iso6346ContainerNumber.require(" ms cu 663987 0 "))
|
||||
.isEqualTo("MSCU6639870");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWrongEquipmentCategoryAndCheckDigit() {
|
||||
assertThatThrownBy(() -> Iso6346ContainerNumber.require("MSCA6639870"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("设备类别");
|
||||
assertThatThrownBy(() -> Iso6346ContainerNumber.require("MSCU6639871"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("校验位");
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.NullAndEmptySource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class RealContainerNumberTest {
|
||||
/** 录入只去空白转大写,ISO 长度、设备类别与校验位不构成门禁。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"JZXH10231043", "MSCA6639870", "MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
void normalizesWithoutIsoValidation(String containerNo) {
|
||||
assertThat(RealContainerNumber.require(" \u00a0" + containerNo.toLowerCase(java.util.Locale.ROOT) + " "))
|
||||
.isEqualTo(containerNo);
|
||||
}
|
||||
|
||||
/** 空柜号不能进入实际作业,包含规范化后为空的 Unicode 空白。 */
|
||||
@ParameterizedTest
|
||||
@NullAndEmptySource
|
||||
@ValueSource(strings = {" ", "\u00a0\u3000"})
|
||||
void rejectsMissingNumber(String containerNo) {
|
||||
assertThatThrownBy(() -> RealContainerNumber.require(containerNo))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不能为空");
|
||||
}
|
||||
}
|
||||
-31
@@ -14,37 +14,6 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PlatformOceanBookingControllerContractTest {
|
||||
/** 预计到港命令仅接收版本与时间,沿用编辑权限及独立审计。 */
|
||||
@Test void estimatedArrivalCommandHasNarrowDtoAndEditPermission() throws Exception {
|
||||
var endpoint = method("updateEstimatedArrival");
|
||||
assertThat(endpoint.getAnnotation(org.springframework.web.bind.annotation.PutMapping.class).value()).containsExactly("/{oceanBookingId}/estimated-arrival");
|
||||
assertThat(endpoint.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:firstleg:ocean-booking:edit')");
|
||||
assertThat(endpoint.getAnnotation(BusinessAudit.class)).isNotNull();
|
||||
assertThat(Arrays.stream(com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.EstimatedArrivalRequest.class.getRecordComponents())
|
||||
.map(java.lang.reflect.RecordComponent::getName)).containsExactly("expectedVersion", "estimatedArrivalAt");
|
||||
assertThat(Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")))
|
||||
.contains("/api/platform/v1/ocean-bookings/{oceanBookingId}/estimated-arrival:", "#/components/schemas/EstimatedArrivalRequest");
|
||||
}
|
||||
|
||||
/** 真实方法权限拦截器拒绝只有读取权限的账号,命令不会进入服务层。 */
|
||||
@Test void estimatedArrivalRejectsReadOnlyAccountBeforeServiceCall() {
|
||||
var service = org.mockito.Mockito.mock(com.baiyue.firstleg.oceanbooking.service.OceanBookingService.class);
|
||||
var target = new PlatformOceanBookingController();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(target, "service", service);
|
||||
var factory = new org.springframework.aop.framework.ProxyFactory(target);
|
||||
factory.setProxyTargetClass(true);
|
||||
factory.addAdvisor(org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor.preAuthorize());
|
||||
var secured = (PlatformOceanBookingController) factory.getProxy();
|
||||
var context = org.springframework.security.core.context.SecurityContextHolder.getContext();
|
||||
context.setAuthentication(new org.springframework.security.authentication.UsernamePasswordAuthenticationToken("reader", "",
|
||||
java.util.List.of(new org.springframework.security.core.authority.SimpleGrantedAuthority("platform:firstleg:ocean-booking:list"))));
|
||||
try {
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> secured.updateEstimatedArrival(null, 1L, "eta-denied",
|
||||
new com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.EstimatedArrivalRequest(3, java.time.LocalDateTime.now())))
|
||||
.isInstanceOf(org.springframework.security.access.AccessDeniedException.class);
|
||||
org.mockito.Mockito.verifyNoInteractions(service);
|
||||
} finally { org.springframework.security.core.context.SecurityContextHolder.clearContext(); }
|
||||
}
|
||||
@Test
|
||||
void usesDedicatedResourceAndPermissions() {
|
||||
assertThat(PlatformOceanBookingController.class.getAnnotation(RequestMapping.class).value())
|
||||
|
||||
-7
@@ -14,13 +14,6 @@ import java.util.Arrays;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OceanBookingMapperContractTest {
|
||||
/** 放舱后预计到港只更新该字段及版本审计,不允许带出其他冻结资料。 */
|
||||
@Test void estimatedArrivalUpdateIsNarrowAndVersioned() throws Exception {
|
||||
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("updateEstimatedArrival", long.class,
|
||||
java.time.LocalDateTime.class, int.class, long.class, String.class).getAnnotation(Update.class).value());
|
||||
assertThat(sql).contains("estimated_arrival_at=#{estimatedArrivalAt}", "status='RELEASED'", "version=#{version}", "version=version+1")
|
||||
.doesNotContain("vessel_name=", "voyage_no=", "estimated_sailing_at=", "actual_", "declared_", "warehouse_id=");
|
||||
}
|
||||
@Test
|
||||
void listUsesPlatformAggregateFilters() throws Exception {
|
||||
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectPage",
|
||||
|
||||
-8
@@ -10,14 +10,6 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OceanBookingSqlContractTest {
|
||||
/** 两份触发器基线只放开预计到港,继续冻结船名、开船、预报和其他运输资料。 */
|
||||
@Test void releasedFreezeAllowsEstimatedArrivalOnly() throws Exception {
|
||||
for (String source : new String[] {sql(), Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))}) {
|
||||
assertThat(source).doesNotContain("NEW.estimated_arrival_at <=> OLD.estimated_arrival_at")
|
||||
.contains("NEW.vessel_name <=> OLD.vessel_name", "NEW.estimated_sailing_at <=> OLD.estimated_sailing_at",
|
||||
"NEW.declared_package_count <=> OLD.declared_package_count", "NEW.version = OLD.version + 1");
|
||||
}
|
||||
}
|
||||
@Test
|
||||
void definesDedicatedAggregateWithoutChangingContainerSemantics() throws Exception {
|
||||
String sql = sql();
|
||||
|
||||
+23
-68
@@ -118,7 +118,7 @@ class OceanBookingServiceTest {
|
||||
assertThat(result.oceanBooking().getStatus()).isEqualTo("RELEASED");
|
||||
assertThat(result.oceanBooking().getCustomerId()).isNull();
|
||||
assertThat(result.oceanBooking().getCustomerName()).isNull();
|
||||
verifyNoInteractions(documents);
|
||||
verify(documents).requireActiveValidDocument(stored.get());
|
||||
verify(customers, times(initialCustomer ? 1 : 0)).active(anyLong(), any());
|
||||
}
|
||||
|
||||
@@ -188,22 +188,21 @@ class OceanBookingServiceTest {
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
/** 无订舱附件时仍可按完整业务资料放舱。 */
|
||||
@Test
|
||||
void releasesWithoutBookingFiles() {
|
||||
void rejectsReleaseWithoutActiveBookingOwnedFileWithoutStateDrift() {
|
||||
stubMasterData();
|
||||
OceanBookingEntity draft = completeBooking("DRAFT", 3);
|
||||
when(repository.find(1L)).thenReturn(draft, completeBooking("RELEASED", 4));
|
||||
when(repository.find(1L)).thenReturn(draft);
|
||||
when(repository.parties(1L)).thenReturn(parties());
|
||||
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
|
||||
when(repository.documents(1L)).thenReturn(List.of());
|
||||
when(repository.transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin")).thenReturn(1);
|
||||
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
|
||||
"订舱缺少有效订舱确认或提单文件"))
|
||||
.when(documents).requireActiveValidDocument(draft);
|
||||
|
||||
Detail result = service.release(1L, 9L, "admin", "release-no-file", new ReleaseRequest(3, "确认"));
|
||||
assertThat(result.oceanBooking().getStatus()).isEqualTo("RELEASED");
|
||||
assertThat(result.documents()).isEmpty();
|
||||
verifyNoInteractions(documents);
|
||||
verify(repository).transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin");
|
||||
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-no-file",
|
||||
new ReleaseRequest(3, "确认")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -232,17 +231,24 @@ class OceanBookingServiceTest {
|
||||
verify(repository, times(1)).transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin");
|
||||
}
|
||||
|
||||
/** 缺预计到港不能放舱,避免关联柜次后丢失日历及默认到仓日期来源。 */
|
||||
/** 不录预计到港仍可放舱,其余资料及附件门禁保持有效。 */
|
||||
@Test
|
||||
void rejectsReleaseWithoutEstimatedArrival() {
|
||||
void releasesWithoutEstimatedArrival() {
|
||||
stubMasterData();
|
||||
OceanBookingEntity draft = completeBooking("DRAFT", 3);
|
||||
OceanBookingEntity released = completeBooking("RELEASED", 4);
|
||||
draft.setEstimatedArrivalAt(null);
|
||||
when(repository.find(1L)).thenReturn(draft);
|
||||
released.setEstimatedArrivalAt(null);
|
||||
when(repository.find(1L)).thenReturn(draft, released);
|
||||
when(repository.parties(1L)).thenReturn(parties());
|
||||
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
|
||||
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-no-arrival", new ReleaseRequest(3, "确认放舱")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("尚未填写完整");
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
|
||||
when(repository.transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin")).thenReturn(1);
|
||||
|
||||
Detail result = service.release(1L, 9L, "admin", "release-no-arrival", new ReleaseRequest(3, "确认放舱"));
|
||||
|
||||
assertThat(result.oceanBooking().getStatus()).isEqualTo("RELEASED");
|
||||
assertThat(result.oceanBooking().getEstimatedArrivalAt()).isNull();
|
||||
verify(documents).requireActiveValidDocument(draft);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -400,57 +406,6 @@ class OceanBookingServiceTest {
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
/** 预计到港独立更新、回显并保留修改前后审计,不能触及其他冻结字段。 */
|
||||
@Test void maintainsReleasedEstimatedArrivalWithVersionAndAudit() {
|
||||
var before = completeBooking("RELEASED", 3);
|
||||
var after = objectMapper.convertValue(before, OceanBookingEntity.class);
|
||||
var arrival = before.getEstimatedSailingAt().plusDays(20);
|
||||
after.setEstimatedArrivalAt(arrival); after.setVersion(4);
|
||||
when(repository.find(1L)).thenReturn(before, after);
|
||||
when(repository.updateEstimatedArrival(1L, arrival, 3, 9L, "admin")).thenReturn(1);
|
||||
|
||||
var result = service.updateEstimatedArrival(1L, 9L, "admin", "eta-save", new EstimatedArrivalRequest(3, arrival));
|
||||
|
||||
assertThat(result.oceanBooking().getEstimatedArrivalAt()).isEqualTo(arrival);
|
||||
assertThat(result.oceanBooking().getVersion()).isEqualTo(4);
|
||||
assertThat(result.oceanBooking().getVesselName()).isEqualTo(before.getVesselName());
|
||||
var history = ArgumentCaptor.forClass(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class);
|
||||
verify(repository).insert(history.capture());
|
||||
assertThat(history.getValue().getAction()).isEqualTo("UPDATE_ESTIMATED_ARRIVAL");
|
||||
assertThat(history.getValue().getBeforeSnapshot()).contains("estimatedArrivalAt");
|
||||
assertThat(history.getValue().getAfterSnapshot()).contains("estimatedArrivalAt");
|
||||
verify(repository, never()).updateDraft(any(), anyInt(), anyLong(), any());
|
||||
verify(repository, never()).replaceRequirements(anyLong(), any());
|
||||
}
|
||||
|
||||
/** 草稿、旧版本、空值及逆序时间均不能走放舱后的预计到港命令。 */
|
||||
@Test void rejectsInvalidEstimatedArrivalCommands() {
|
||||
var value = completeBooking("DRAFT", 3);
|
||||
when(repository.find(1L)).thenReturn(value);
|
||||
var arrival = value.getEstimatedSailingAt().plusDays(20);
|
||||
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-draft", new EstimatedArrivalRequest(3, arrival))).isInstanceOf(BizException.class);
|
||||
value.setStatus("RELEASED");
|
||||
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-stale", new EstimatedArrivalRequest(2, arrival))).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-empty", new EstimatedArrivalRequest(3, null))).hasMessageContaining("不能为空");
|
||||
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-early", new EstimatedArrivalRequest(3, value.getEstimatedSailingAt().minusSeconds(1)))).hasMessageContaining("不能早于");
|
||||
when(repository.updateEstimatedArrival(anyLong(), any(), anyInt(), anyLong(), any())).thenReturn(0);
|
||||
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-conflict", new EstimatedArrivalRequest(3, arrival))).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
/** 同一预计到港命令重放复用原结果,不重复保存或追加审计。 */
|
||||
@Test void estimatedArrivalReplayDoesNotWriteAgain() throws Exception {
|
||||
var request = new EstimatedArrivalRequest(3, LocalDateTime.of(2026, 10, 25, 10, 0));
|
||||
var replay = new Detail(completeBooking("RELEASED", 4), List.of(), List.of(), List.of(), List.of(), List.of());
|
||||
var operation = new OceanBookingOperationEntity();
|
||||
operation.setRequestHash(hash("UPDATE_ESTIMATED_ARRIVAL", 1L, request));
|
||||
operation.setStatus("SUCCESS"); operation.setResultJson(objectMapper.writeValueAsString(replay));
|
||||
when(repository.claimOperation(1L, 9L, "UPDATE_ESTIMATED_ARRIVAL", "eta-replay", operation.getRequestHash())).thenReturn(false);
|
||||
when(repository.operation(9L, "UPDATE_ESTIMATED_ARRIVAL", "eta-replay")).thenReturn(operation);
|
||||
assertThat(service.updateEstimatedArrival(1L, 9L, "admin", "eta-replay", request).oceanBooking().getVersion()).isEqualTo(4);
|
||||
verify(repository, never()).updateEstimatedArrival(anyLong(), any(), anyInt(), anyLong(), any());
|
||||
verify(repository, never()).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void supplementsReleasedActualCargoWithVersionAndBeforeAfterAudit() {
|
||||
OceanBookingEntity before = completeBooking("RELEASED", 3);
|
||||
|
||||
+3
-47
@@ -8,23 +8,12 @@ import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@DisplayName("货柜数据库映射契约")
|
||||
class ContainerBookingMapperContractTest {
|
||||
@@ -117,7 +106,7 @@ class ContainerBookingMapperContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("初始化与统一增量保留柜号权威及订舱内唯一,不限制关联柜号ISO格式")
|
||||
@DisplayName("初始化与统一增量锁定真实柜号权威、订舱内唯一和ISO结构约束")
|
||||
void databaseContractsFreezeAuthoritativeContainerNumber() throws Exception {
|
||||
String module = Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql"));
|
||||
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
@@ -129,41 +118,8 @@ class ContainerBookingMapperContractTest {
|
||||
assertThat(source.toLowerCase())
|
||||
.contains("unique key uk_container_booking_ocean_container (ocean_booking_id,container_no)")
|
||||
.contains("key idx_container_booking_container_no (container_no,updated_at,id)")
|
||||
.doesNotContain("chk_container_booking_iso6346", "^[a-z]{3}[ujz][0-9]{7}$");
|
||||
.contains("constraint chk_container_booking_iso6346")
|
||||
.contains("^[a-z]{3}[ujz][0-9]{7}$");
|
||||
}
|
||||
}
|
||||
|
||||
/** 从当前建表基线创建隔离数据库,真实执行关联 Mapper 并验证唯一键和版本。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
void linksNonIsoContainerNumbersThroughActualSchemaAndMapper(String containerNo) throws Exception {
|
||||
var dataSource = new JdbcDataSource();
|
||||
dataSource.setURL("jdbc:h2:mem:container-link-" + java.util.UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
var jdbc = new JdbcTemplate(dataSource);
|
||||
String source = Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql"));
|
||||
int start = source.indexOf("CREATE TABLE IF NOT EXISTS fl_container_booking (");
|
||||
jdbc.execute(source.substring(start, source.indexOf(';', source.indexOf(") ENGINE=InnoDB", start)) + 1));
|
||||
for (long id = 1; id <= 3; id++) {
|
||||
jdbc.update("INSERT INTO fl_container_booking(id,cabinet_no,warehouse_id,warehouse_name,container_type,created_by,updated_by) VALUES(?,?,8,'测试仓','40HQ',9,9)",
|
||||
id, "D" + id);
|
||||
}
|
||||
var configuration = new MybatisConfiguration();
|
||||
configuration.setMapUnderscoreToCamelCase(true);
|
||||
configuration.addMapper(ContainerBookingMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(dataSource);
|
||||
factory.setConfiguration(configuration);
|
||||
var session = new SqlSessionTemplate(factory.getObject());
|
||||
var repository = new ContainerRepository();
|
||||
ReflectionTestUtils.setField(repository, "mapper", session.getMapper(ContainerBookingMapper.class));
|
||||
var booking = new OceanBookingEntity();
|
||||
|
||||
assertThat(repository.linkReleasedBooking(1L, 21L, 31L, containerNo, booking, 0, 9L, "操作员")).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject("SELECT container_no FROM fl_container_booking WHERE id=1", String.class)).isEqualTo(containerNo);
|
||||
assertThat(jdbc.queryForObject("SELECT version FROM fl_container_booking WHERE id=1", Integer.class)).isEqualTo(1);
|
||||
assertThat(repository.linkReleasedBooking(1L, 21L, 31L, containerNo, booking, 0, 9L, "操作员")).isZero();
|
||||
assertThatThrownBy(() -> repository.linkReleasedBooking(2L, 21L, 31L, containerNo, booking, 0, 9L, "操作员"))
|
||||
.isInstanceOf(DuplicateKeyException.class);
|
||||
assertThat(repository.linkReleasedBooking(3L, 22L, 32L, containerNo, booking, 0, 9L, "操作员")).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
-107
@@ -1,107 +0,0 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
||||
import com.baiyue.firstleg.mapper.InboundPricingMapper;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
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.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** 通过真实 Mapper 执行费用状态更新,防止业务层放行但持久化条件遗漏作业状态。 */
|
||||
class FbaPricingFeeTransitionTest {
|
||||
private SqlSession session;
|
||||
private JdbcTemplate sql;
|
||||
private FbaPricingRepository repository;
|
||||
|
||||
/** 每项测试使用独立内存表,保留真实的客户和逻辑删除条件。 */
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
var ds = new JdbcDataSource();
|
||||
ds.setURL("jdbc:h2:mem:fee-transition-" + UUID.randomUUID() + ";MODE=MySQL");
|
||||
var config = new MybatisConfiguration();
|
||||
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
|
||||
config.addMapper(InboundPricingMapper.class);
|
||||
session = new MybatisSqlSessionFactoryBuilder().build(config).openSession(true);
|
||||
session.getConnection();
|
||||
sql = new JdbcTemplate(ds);
|
||||
sql.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT," +
|
||||
"process_status VARCHAR(32),fee_status VARCHAR(32),deleted BOOLEAN DEFAULT FALSE)");
|
||||
repository = new FbaPricingRepository();
|
||||
ReflectionTestUtils.setField(repository, "inbounds", session.getMapper(InboundPricingMapper.class));
|
||||
}
|
||||
|
||||
/** 关闭最后一个连接后销毁本项测试的内存数据库。 */
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (session != null) session.close();
|
||||
}
|
||||
|
||||
/** 费用确认和发送不得提前完成一件代发组托或回退已有履约进度。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
|
||||
void confirmsAndSendsWithoutAdvancingPalletization(String processStatus) {
|
||||
insertInbound(processStatus, "CALCULATED");
|
||||
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isEqualTo(1);
|
||||
assertState(processStatus, "CONFIRMED");
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isEqualTo(1);
|
||||
assertState("FEE_CONFIRMING".equals(processStatus) ? "WAIT_PALLET" : processStatus, "SENT");
|
||||
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isZero();
|
||||
}
|
||||
|
||||
/** 待组托只补费用确认资格,不能绕过费用快照的前置状态。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"UNPRICED", "CONFIRMED", "SENT", "VOIDED"})
|
||||
void rejectsConfirmationUnlessFeeIsCalculated(String feeStatus) {
|
||||
insertInbound("WAIT_PALLETIZE", feeStatus);
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
assertState("WAIT_PALLETIZE", feeStatus);
|
||||
}
|
||||
|
||||
/** 非确认阶段、其他客户和已删除入库单仍不可确认。 */
|
||||
@Test
|
||||
void preservesProcessCustomerAndDeletionGuards() {
|
||||
insertInbound("WAIT_MEASURE", "CALCULATED");
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
sql.update("UPDATE fl_inbound_order SET process_status='WAIT_PALLETIZE' WHERE id=88");
|
||||
assertThat(repository.markInboundConfirmed(20002L, 88L)).isZero();
|
||||
sql.update("UPDATE fl_inbound_order SET deleted=TRUE WHERE id=88");
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
assertState("WAIT_PALLETIZE", "CALCULATED");
|
||||
}
|
||||
|
||||
/** 模拟发送前作业状态发生变化,旧状态条件须拒绝写入。 */
|
||||
@Test
|
||||
void rejectsSendWithStaleProcessStatus() {
|
||||
insertInbound("WAIT_SPLIT", "CONFIRMED");
|
||||
assertThat(repository.markInboundFeeSent(10001L, 88L, "WAIT_PALLETIZE")).isZero();
|
||||
assertState("WAIT_SPLIT", "CONFIRMED");
|
||||
}
|
||||
|
||||
/** 准备单个客户的入库单,不调用共享环境。 */
|
||||
private void insertInbound(String processStatus, String feeStatus) {
|
||||
sql.update("INSERT INTO fl_inbound_order(id,tenant_id,process_status,fee_status) VALUES(88,10001,?,?)",
|
||||
processStatus, feeStatus);
|
||||
}
|
||||
|
||||
/** 同时核对物理作业状态与费用状态,防止用改主流程掩盖发送问题。 */
|
||||
private void assertState(String processStatus, String feeStatus) {
|
||||
assertThat(sql.queryForObject("SELECT process_status FROM fl_inbound_order WHERE id=88", String.class))
|
||||
.isEqualTo(processStatus);
|
||||
assertThat(sql.queryForObject("SELECT fee_status FROM fl_inbound_order WHERE id=88", String.class))
|
||||
.isEqualTo(feeStatus);
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -194,9 +194,8 @@ class FbaPricingRepositoryRuleListTest {
|
||||
.contains(10001L, 88L, "FEE_CONFIRMING", "CONFIRMED", "SENT", "WAIT_PALLET");
|
||||
}
|
||||
|
||||
/** 费用确认状态集覆盖待组托及后续履约阶段。 */
|
||||
@Test
|
||||
void feeConfirmationAllowsPalletizationAndPostLoadingInboundStatuses() {
|
||||
void feeConfirmationAllowsPostLoadingInboundStatuses() {
|
||||
String source;
|
||||
try {
|
||||
source = Files.readString(Path.of(
|
||||
@@ -206,7 +205,7 @@ class FbaPricingRepositoryRuleListTest {
|
||||
throw new AssertionError(exception);
|
||||
}
|
||||
assertThat(source)
|
||||
.contains("List.of(\"FEE_CONFIRMING\", \"WAIT_PALLET\", \"WAIT_PALLETIZE\", \"WAIT_SPLIT\", \"LOADING\", \"LOADED\", \"DELIVERED\")");
|
||||
.contains("List.of(\"FEE_CONFIRMING\", \"WAIT_PALLET\", \"WAIT_SPLIT\", \"LOADING\", \"LOADED\", \"DELIVERED\")");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+3
-5
@@ -10,7 +10,6 @@ import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TruckAppointmentMapperContractTest {
|
||||
/** 候选不依赖附件,仍保留提单号、状态、唯一预约和乐观锁条件。 */
|
||||
@Test void mapsToPhase3TableAndUsesOptimisticGuards() throws Exception {
|
||||
assertThat(TruckAppointmentEntity.class.getAnnotation(TableName.class).value()).isEqualTo("fl_truck_appointment");
|
||||
String page = TruckAppointmentMapper.class.getMethod("page", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.Query.class).getAnnotation(Select.class).value()[0];
|
||||
@@ -19,10 +18,9 @@ class TruckAppointmentMapperContractTest {
|
||||
.doesNotContain("fl_container_booking", "cabinet_no", "container_no");
|
||||
assertThat(page).doesNotContain("customer_id", "customerId", "customer_name");
|
||||
String candidates = TruckAppointmentMapper.class.getMethod("candidates", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.CandidateQuery.class).getAnnotation(Select.class).value()[0];
|
||||
assertThat(candidates).contains("FROM fl_ocean_booking o", "o.status='RELEASED'",
|
||||
"o.bill_of_lading_no IS NOT NULL", "o.bill_of_lading_no<>''", "t.ocean_booking_id=o.id",
|
||||
"t.status NOT IN ('CANCELLED','EXCEPTION')");
|
||||
assertThat(candidates).doesNotContain("fl_ocean_booking_document", "bookingDocumentReady");
|
||||
assertThat(candidates).contains("FROM fl_ocean_booking o", "o.status='RELEASED'", "d.valid=1",
|
||||
"'BOOKING_CONFIRMATION','BILL_OF_LADING'", "t.ocean_booking_id=o.id",
|
||||
"t.status NOT IN ('CANCELLED','EXCEPTION')", "bookingDocumentReady");
|
||||
assertThat(candidates).doesNotContain("fl_ocean_booking_container_allocation", "fl_container_booking",
|
||||
"loadingPlanId", "containerBookingId", "cabinetNo", "containerNo");
|
||||
String transition = TruckAppointmentMapper.class.getMethod("transition", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
|
||||
|
||||
@@ -297,23 +297,21 @@ class ContainerServiceTest {
|
||||
verifyNoInteractions(oceanBookings);
|
||||
}
|
||||
|
||||
/** 提柜补录使用相同的非 ISO 规则,并按版本原子保存。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "LOCAL-2026-001"})
|
||||
@Test
|
||||
@DisplayName("提柜后按柜次版本原子补录真实柜号")
|
||||
void assignsRealContainerNumberAfterPickup(String containerNo) {
|
||||
void assignsRealContainerNumberAfterPickup() {
|
||||
ContainerBookingEntity linked = booking("EDITING", 3);
|
||||
linked.setContainerNo(null); linked.setOceanBookingId(21L); linked.setOceanBookingRequirementId(31L);
|
||||
ContainerBookingEntity assigned = booking("EDITING", 4);
|
||||
assigned.setContainerNo(containerNo); assigned.setOceanBookingId(21L); assigned.setOceanBookingRequirementId(31L);
|
||||
assigned.setContainerNo("MSCU6639870"); assigned.setOceanBookingId(21L); assigned.setOceanBookingRequirementId(31L);
|
||||
when(repository.find(11L)).thenReturn(linked, assigned);
|
||||
when(repository.assignRealContainerNo(11L, containerNo, 3, 9L, "admin")).thenReturn(1);
|
||||
when(repository.assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin")).thenReturn(1);
|
||||
|
||||
ContainerBookingEntity result = service.assignRealContainerNo(
|
||||
11L, " " + containerNo.toLowerCase(java.util.Locale.ROOT) + " ", 3, 9L, "admin");
|
||||
11L, " ms cu 663987 0 ", 3, 9L, "admin");
|
||||
|
||||
assertThat(result.getContainerNo()).isEqualTo(containerNo);
|
||||
verify(repository).assignRealContainerNo(11L, containerNo, 3, 9L, "admin");
|
||||
assertThat(result.getContainerNo()).isEqualTo("MSCU6639870");
|
||||
verify(repository).assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -331,21 +329,17 @@ class ContainerServiceTest {
|
||||
verify(repository, never()).assignRealContainerNo(anyLong(), anyString(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
/** 关联入口允许非 ISO 柜号,继续按原版本和关联范围保存。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
@DisplayName("首次关联不因柜号长度结构或校验位阻断")
|
||||
void linksWithoutIsoContainerNumberValidation(String containerNo) {
|
||||
@Test
|
||||
@DisplayName("首次关联拒绝错误ISO 6346校验位")
|
||||
void rejectsInvalidContainerNumberCheckDigit() {
|
||||
stubLinkContext();
|
||||
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq(containerNo),
|
||||
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"))).thenReturn(1);
|
||||
|
||||
service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(21L, 31L, containerNo, 2));
|
||||
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
|
||||
new OceanBookingLinkRequest(21L, 31L, "MSCU6639871", 2)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("校验位");
|
||||
|
||||
verify(repository).containerNoExists(21L, containerNo, 11L);
|
||||
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq(containerNo),
|
||||
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"));
|
||||
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
|
||||
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
|
||||
any(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -22,9 +22,6 @@ import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.R
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
@@ -226,13 +223,11 @@ class FbaPricingServiceTest {
|
||||
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
|
||||
}
|
||||
|
||||
/** 待组托、待分柜确认费用均不推进物流主流程。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"WAIT_PALLETIZE", "WAIT_SPLIT"})
|
||||
@DisplayName("待组托或待分柜确认费用后仍保持主流程状态")
|
||||
void confirmsWithoutRollingBackProcessStatus(String processStatus) {
|
||||
@Test
|
||||
@DisplayName("待分柜边界确认费用后仍保持主流程状态")
|
||||
void confirmsAtWaitSplitWithoutRollingBackProcessStatus() {
|
||||
InboundPricingEntity inbound = inbound("10.000", "1.0000");
|
||||
inbound.setProcessStatus(processStatus);
|
||||
inbound.setProcessStatus("WAIT_SPLIT");
|
||||
inbound.setFeeStatus("CALCULATED");
|
||||
InboundFeeEntity fee = savedFee("CALCULATED");
|
||||
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee);
|
||||
@@ -249,7 +244,7 @@ class FbaPricingServiceTest {
|
||||
FeeSnapshotResponse response = service.confirm(
|
||||
TENANT, OPERATOR, INBOUND, "confirm-wait-split");
|
||||
|
||||
assertThat(response.nextProcessStatus()).isEqualTo(processStatus);
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
|
||||
verify(repository).markInboundConfirmed(TENANT, INBOUND);
|
||||
}
|
||||
|
||||
@@ -441,13 +436,11 @@ class FbaPricingServiceTest {
|
||||
verify(repository, never()).insertFee(any());
|
||||
}
|
||||
|
||||
/** 待组托发送费用保留主状态,重复请求不重复生成费用事件。 */
|
||||
@ParameterizedTest
|
||||
@CsvSource({"FEE_CONFIRMING,WAIT_PALLET", "WAIT_PALLETIZE,WAIT_PALLETIZE"})
|
||||
@Test
|
||||
@DisplayName("重复发送只返回首次结果且不会重复写Outbox")
|
||||
void sendIsIdempotentAndDoesNotDuplicateOutbox(String processStatus, String expectedStatus) {
|
||||
void sendIsIdempotentAndDoesNotDuplicateOutbox() {
|
||||
InboundPricingEntity inbound = inbound("10", "1");
|
||||
inbound.setProcessStatus(processStatus);
|
||||
inbound.setProcessStatus("FEE_CONFIRMING");
|
||||
inbound.setFeeStatus("CONFIRMED");
|
||||
InboundFeeEntity confirmed = savedFee("CONFIRMED");
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
@@ -455,7 +448,7 @@ class FbaPricingServiceTest {
|
||||
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_SEND", "send-once"))
|
||||
.thenReturn(null, completed("FEE_SEND", 501L));
|
||||
when(repository.sendFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1);
|
||||
when(repository.markInboundFeeSent(TENANT, INBOUND, processStatus)).thenReturn(1);
|
||||
when(repository.markInboundFeeSent(TENANT, INBOUND, "FEE_CONFIRMING")).thenReturn(1);
|
||||
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
|
||||
when(repository.findFee(TENANT, 501L)).thenReturn(confirmed);
|
||||
InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00"));
|
||||
@@ -466,8 +459,8 @@ class FbaPricingServiceTest {
|
||||
FeeSnapshotResponse first = service.send(TENANT, OPERATOR, INBOUND, "send-once");
|
||||
FeeSnapshotResponse repeated = service.send(TENANT, OPERATOR, INBOUND, "send-once");
|
||||
|
||||
assertThat(first.nextProcessStatus()).isEqualTo(expectedStatus);
|
||||
assertThat(repeated.nextProcessStatus()).isEqualTo(expectedStatus);
|
||||
assertThat(first.nextProcessStatus()).isEqualTo("WAIT_PALLET");
|
||||
assertThat(repeated.nextProcessStatus()).isEqualTo("WAIT_PALLET");
|
||||
verify(financeOutbox, times(1)).publishInbound(
|
||||
eq(FinanceFeeSourceOutboxService.PUBLISHED), eq(TENANT), eq(INBOUND),
|
||||
eq(501L), eq(1), eq(NOW));
|
||||
|
||||
-17
@@ -101,23 +101,6 @@ class LoadingCompletedOutboxServiceTest {
|
||||
assertThat(first.eventVersion()).isEqualTo(3);
|
||||
}
|
||||
|
||||
/** 非 ISO 柜号能生成当前 V3 事件,并保持冻结柜号原值。 */
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "LOCAL-2026-001"})
|
||||
void publishesNonIsoContainerNumber(String containerNo) throws Exception {
|
||||
Row value = row(11, 100, 101);
|
||||
value.setContainerNo(containerNo);
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||
|
||||
service.publish(90L);
|
||||
|
||||
ArgumentCaptor<InboundOutboxEntity> captured = ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||
verify(outbox).insert(captured.capture());
|
||||
EventEnvelope event = mapper.readValue(captured.getValue().getPayloadJson(), EventEnvelope.class);
|
||||
assertThat(event.eventVersion()).isEqualTo(3);
|
||||
assertThat(event.payload().path("containerNo").asText()).isEqualTo(containerNo);
|
||||
}
|
||||
|
||||
@Test void requiresFrozenPhysicalContainerNumberBeforePublishing() {
|
||||
Row value = row(11, 100, 101); value.setContainerNo(" ");
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||
|
||||
@@ -26,9 +26,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.NullSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
@@ -150,45 +147,10 @@ class LoadingListServiceTest {
|
||||
verifyNoInteractions(loadingPickService);
|
||||
}
|
||||
|
||||
/** 只更新航次时,关联已接受的非 ISO 柜号不应被重新校验或改写。 */
|
||||
@ParameterizedTest
|
||||
@NullSource
|
||||
@ValueSource(strings = {"LOCAL-1", " local-1 "})
|
||||
void preservesLinkedNonIsoNumberWhenOnlyVoyageChanges(String requested) {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setContainerNo("LOCAL-1");
|
||||
before.setVoyageNo("001E");
|
||||
when(repository.aggregate(10L)).thenReturn(before);
|
||||
when(repository.updateTransport(10L, 3, "LOCAL-1", "023E", 9L, "operator")).thenReturn(1);
|
||||
|
||||
service.updateTransport(10L, 100L, 9L, "operator", "preserve-linked-number",
|
||||
new TransportUpdateRequest(requested, "023E", 3, null));
|
||||
|
||||
verify(repository).updateTransport(10L, 3, "LOCAL-1", "023E", 9L, "operator");
|
||||
verify(loadingPickService).beginIfReady(30L, 9L, "operator");
|
||||
}
|
||||
|
||||
/** 运输纠错与关联入口一致,只规范化柜号并保留纠错原因。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
void acceptsNonIsoNumberInTransportCorrection(String containerNo) {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setContainerNo("LOCAL-1");
|
||||
when(repository.aggregate(10L)).thenReturn(before);
|
||||
when(repository.updateTransport(10L, 3, containerNo, null, 9L, "operator")).thenReturn(1);
|
||||
|
||||
service.updateTransport(10L, 100L, 9L, "operator", "non-iso-correction",
|
||||
new TransportUpdateRequest(" " + containerNo.toLowerCase(java.util.Locale.ROOT) + " ", null, 3, "纠错"));
|
||||
|
||||
verify(repository).updateTransport(10L, 3, containerNo, null, 9L, "operator");
|
||||
verify(loadingPickService).beginIfReady(30L, 9L, "operator");
|
||||
}
|
||||
|
||||
/** 关联入口接受的非 ISO 柜号,在 PDA 领取前仍可按版本和原因纠正为真实柜号。 */
|
||||
@Test
|
||||
void pushedEditingPreloadCanCorrectExistingContainerNumberBeforePdaClaim() {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setContainerNo("LOCAL-1");
|
||||
before.setContainerNo("MSCU6639870");
|
||||
AggregateRow after = row("EDITING", 4);
|
||||
after.setContainerNo("CSQU3054383");
|
||||
when(repository.aggregate(10L)).thenReturn(before, after);
|
||||
@@ -238,7 +200,7 @@ class LoadingListServiceTest {
|
||||
|
||||
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1",
|
||||
new TransportUpdateRequest(" ", "023E", 3, "清空测试")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不能为空");
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
|
||||
verify(repository, never()).updateTransport(eq(10L), eq(3),
|
||||
org.mockito.ArgumentMatchers.nullable(String.class),
|
||||
org.mockito.ArgumentMatchers.nullable(String.class), eq(9L),
|
||||
|
||||
@@ -227,7 +227,7 @@ class LoadingPickServiceTest {
|
||||
Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1,
|
||||
10L, 40L, "MSCU-001", "EDITING", 3, 2, null);
|
||||
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
|
||||
booking.setId(40L); booking.setContainerNo("MSCU6639870");
|
||||
booking.setId(40L); booking.setContainerNo("MSCU-001");
|
||||
var allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setOceanBookingId(90L);
|
||||
var ocean = new OceanBookingEntity();
|
||||
@@ -962,82 +962,6 @@ class LoadingPickServiceTest {
|
||||
verify(repository, never()).acceptCompletion(anyLong(), anyInt());
|
||||
}
|
||||
|
||||
/** 关联接受的非 ISO 柜号可直接确认预装,保留订舱放舱门禁。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
void acceptsNonIsoNumberBeforeConfirmation(String containerNo) {
|
||||
Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1,
|
||||
10L, 40L, containerNo, "EDITING", 3, 2, null);
|
||||
var booking = new ContainerBookingEntity();
|
||||
booking.setId(40L);
|
||||
booking.setContainerNo(containerNo);
|
||||
when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link));
|
||||
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
|
||||
|
||||
var allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setOceanBookingId(90L);
|
||||
var ocean = new OceanBookingEntity();
|
||||
ocean.setId(90L);
|
||||
ocean.setStatus("RELEASED");
|
||||
when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation);
|
||||
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
|
||||
assertThat(service.requirePreloadConfirmationReady(10L)).containsExactly(link);
|
||||
}
|
||||
|
||||
/** 非 ISO 柜号可以创建 PDA 任务,任务仍按柜次关联权威柜号。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
void createsPdaTaskWithNonIsoNumber(String containerNo) {
|
||||
var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity();
|
||||
plan.setId(10L); plan.setContainerBookingId(40L);
|
||||
var booking = new ContainerBookingEntity();
|
||||
booking.setId(40L); booking.setContainerNo(containerNo); booking.setCabinetNo("D2092");
|
||||
when(smartLoadingRepository.plan(10L)).thenReturn(plan);
|
||||
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
|
||||
doAnswer(invocation -> {
|
||||
LoadingPickTaskEntity created = invocation.getArgument(0);
|
||||
created.setId(20L);
|
||||
return 1;
|
||||
}).when(repository).insert(any(LoadingPickTaskEntity.class));
|
||||
|
||||
service.begin(10L, containerNo, 9L, "operator", List.of());
|
||||
|
||||
ArgumentCaptor<LoadingPickTaskEntity> captured = ArgumentCaptor.forClass(LoadingPickTaskEntity.class);
|
||||
verify(repository).insert(captured.capture());
|
||||
assertThat(captured.getValue().getContainerBookingId()).isEqualTo(40L);
|
||||
assertThat(captured.getValue().getTaskStatus()).isEqualTo("LOADING");
|
||||
}
|
||||
|
||||
/** 去除 ISO 校验不应放开缺号任务。 */
|
||||
@ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.NullAndEmptySource
|
||||
@ValueSource(strings = {" ", "\u00a0\u3000"})
|
||||
void rejectsMissingNumberBeforeCreatingPdaTask(String containerNo) {
|
||||
assertThatThrownBy(() -> service.begin(10L, containerNo, 9L, "operator", List.of()))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("录入真实集装箱号");
|
||||
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
|
||||
}
|
||||
|
||||
/** 非 ISO 柜号不会在完成阶段再次受阻,仍通过持久化命令结算。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
void completionAcceptsNonIsoNumberForCrossServiceFacts(String containerNo) {
|
||||
repository.activeBookingByTask(20L).setContainerNo(containerNo);
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task());
|
||||
LoadingPickTaskEntity completing = task();
|
||||
completing.setTaskStatus("COMPLETING");
|
||||
completing.setVersion(1);
|
||||
when(repository.task(20L)).thenReturn(completing);
|
||||
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
|
||||
|
||||
Task result = service.complete(20L,
|
||||
new CompleteLoadingRequest(0, "complete-non-iso", "checked"), 9L, "operator");
|
||||
|
||||
assertThat(result.taskStatus()).isEqualTo("COMPLETING");
|
||||
verify(completionCommands).insert(any());
|
||||
verify(repository).acceptCompletion(20L, 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentOperationIdIsRejectedImmediatelyWithConflict() {
|
||||
// different operationId is a canonical A0409 CONFLICT while the durable command is active.
|
||||
|
||||
+4
-20
@@ -36,9 +36,8 @@ class MerchantInboundBillingServiceTest {
|
||||
@MockBean private InboundFeeDetailService feeDetails;
|
||||
@Resource private MerchantInboundBillingService service;
|
||||
|
||||
/** 测量审核通过并发送费用后,待组托与后续履约阶段均可查看客户费用。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
|
||||
@ValueSource(strings = {"WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
|
||||
void shouldKeepPublishedBillingVisibleAfterFulfillmentProgresses(String processStatus) {
|
||||
InboundOrderEntity inbound = inbound(processStatus, "APPROVED", "SENT");
|
||||
InboundOrderDetail detail = new InboundOrderDetail(mock(com.baiyue.firstleg.dto.InboundOrderSummary.class),
|
||||
@@ -85,11 +84,9 @@ class MerchantInboundBillingServiceTest {
|
||||
verify(orders).findByOrderId(10001L, 77L);
|
||||
}
|
||||
|
||||
/** 待组托不绕过客户费用可见性的测量审核条件。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"WAIT_PALLETIZE", "WAIT_SPLIT"})
|
||||
void shouldHandleCase02(String processStatus) {
|
||||
InboundOrderEntity inbound = inbound(processStatus, "PENDING", "SENT");
|
||||
@Test
|
||||
void shouldHandleCase02() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_SPLIT", "PENDING", "SENT");
|
||||
when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound);
|
||||
|
||||
assertThatThrownBy(() -> service.find(10001L, 77L))
|
||||
@@ -108,19 +105,6 @@ class MerchantInboundBillingServiceTest {
|
||||
verify(pricing, never()).latest(20002L, 88L);
|
||||
}
|
||||
|
||||
/** 客户可在组托前确认已审核并发送的费用,继续使用当前费用版本和幂等键。 */
|
||||
@Test
|
||||
void confirmsPublishedFeeWhileWaitingForPalletization() {
|
||||
when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound("WAIT_PALLETIZE", "APPROVED", "SENT"));
|
||||
FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class);
|
||||
when(pricing.confirmByCustomer(10001L, 7L, "客户成员", 88L, 3, "confirm-palletize"))
|
||||
.thenReturn(fee);
|
||||
|
||||
assertThat(service.confirmFee(10001L, 7L, "客户成员", 77L, 3, "confirm-palletize"))
|
||||
.isSameAs(fee);
|
||||
verify(pricing).confirmByCustomer(10001L, 7L, "客户成员", 88L, 3, "confirm-palletize");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleCase04() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_SPLIT", "APPROVED", "SENT");
|
||||
|
||||
+23
-16
@@ -84,7 +84,7 @@ class TruckAppointmentServiceTest {
|
||||
}
|
||||
|
||||
@Test void createCandidatesDelegateOneBoundedAuthoritativeQuery() {
|
||||
Candidate candidate = new Candidate(99L, "OB-1", "BL-1", 18L, "义乌仓", "SEA-US", "美西");
|
||||
Candidate candidate = new Candidate(99L, "OB-1", "BL-1", 18L, "义乌仓", "SEA-US", "美西", true);
|
||||
when(repository.candidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1));
|
||||
|
||||
PageResult<Candidate> result = service.candidates(new CandidateQuery(" OB-1 ", 1, 20));
|
||||
@@ -170,8 +170,7 @@ class TruckAppointmentServiceTest {
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
/** 无附件订舱可创建预约,提单号仍取订舱冻结值。 */
|
||||
@Test void createFreezesBookingBillWithoutRequiringBookingDocument() {
|
||||
@Test void createFreezesBookingBillAndRequiresBookingOwnedDocument() {
|
||||
SaveRequest request = validRequest();
|
||||
|
||||
service.create(7L, "admin", request);
|
||||
@@ -179,7 +178,7 @@ class TruckAppointmentServiceTest {
|
||||
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
|
||||
verify(repository).insert(entity.capture());
|
||||
assertThat(entity.getValue().getBillOfLadingNo()).isEqualTo("BL-BOOKING-1");
|
||||
verifyNoInteractions(oceanBookingDocuments);
|
||||
verify(oceanBookingDocuments).requireActiveValidDocument(any(OceanBookingEntity.class));
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
@@ -308,14 +307,14 @@ class TruckAppointmentServiceTest {
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
/** 没有附件或货柜关联时,完整已放舱订舱仍可预约。 */
|
||||
@Test void createAllowsBookingWithoutDocumentsOrContainerAssociation() {
|
||||
@Test void createStillRequiresBookingDocumentWithoutAnyContainerAssociation() {
|
||||
SaveRequest request = validRequest();
|
||||
TruckAppointmentEntity result = service.create(7L, "admin", request);
|
||||
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.DRAFT);
|
||||
assertThat(result.getContainerBookingId()).isNull();
|
||||
verify(repository).insert(any());
|
||||
verifyNoInteractions(oceanBookingDocuments);
|
||||
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
|
||||
"订舱缺少有效订舱确认或提单文件"))
|
||||
.when(oceanBookingDocuments).requireActiveValidDocument(any());
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test void editCanSwitchToAnotherUnoccupiedReleasedBooking() {
|
||||
@@ -408,8 +407,7 @@ class TruckAppointmentServiceTest {
|
||||
verify(repository, never()).history(any());
|
||||
}
|
||||
|
||||
/** 取消附件要求后,派车仍必须具备提单号。 */
|
||||
@Test void dispatchRequiresBookingBill() {
|
||||
@Test void dispatchRequiresBookingBillAndActiveBookingOwnedDocument() {
|
||||
TruckAppointmentEntity missingBill = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
|
||||
missingBill.setOceanBookingId(99L); missingBill.setContainerBookingId(88L);
|
||||
OceanBookingEntity bookingWithoutBill = releasedBooking(); bookingWithoutBill.setBillOfLadingNo(null);
|
||||
@@ -421,11 +419,21 @@ class TruckAppointmentServiceTest {
|
||||
"dispatch-no-bill", null, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("提单号");
|
||||
|
||||
TruckAppointmentEntity missingAttachment = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
|
||||
missingAttachment.setOceanBookingId(99L); missingAttachment.setContainerBookingId(88L); missingAttachment.setBillOfLadingNo("BL-BOOKING-1");
|
||||
when(oceanBookings.find(99L)).thenReturn(releasedBooking());
|
||||
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-file"), anyString())).thenReturn(true);
|
||||
when(repository.find(1L)).thenReturn(missingAttachment);
|
||||
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,"订舱缺少有效订舱确认或提单文件"))
|
||||
.when(oceanBookingDocuments).requireActiveValidDocument(any());
|
||||
|
||||
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
|
||||
"dispatch-no-file", null, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
|
||||
verify(repository, never()).dispatch(anyLong(), anyString(), anyString(), anyInt());
|
||||
}
|
||||
|
||||
/** 无附件、无真实柜号时可派车,但仍冻结已放舱订舱的提单号并记历史。 */
|
||||
@Test void dispatchFreezesReleasedBookingBillWithoutRequiringDocumentsOrRealContainerNumber() {
|
||||
@Test void dispatchFreezesReleasedBookingBillWithoutRequiringRealContainerNumber() {
|
||||
TruckAppointmentEntity before = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
|
||||
before.setOceanBookingId(99L); before.setContainerBookingId(88L); before.setBillOfLadingNo(null);
|
||||
TruckAppointmentEntity after = entity(TruckAppointmentStatus.DISPATCHED, 2);
|
||||
@@ -444,7 +452,6 @@ class TruckAppointmentServiceTest {
|
||||
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.DISPATCHED);
|
||||
assertThat(result.getBillOfLadingNo()).isEqualTo("BL-BOOKING-1");
|
||||
verify(repository).dispatch(1L, "PENDING_DISPATCH", "BL-BOOKING-1", 1);
|
||||
verifyNoInteractions(oceanBookingDocuments);
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
|
||||
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
|
||||
}
|
||||
|
||||
+2
-37
@@ -11,41 +11,6 @@ import java.time.LocalDateTime;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ContainerCalendarSourceMapperTest {
|
||||
/** 两个日历来源入口均优先展示港口名称,仅填代码时仍可回显,缺失时保持空值。 */
|
||||
@Test
|
||||
void displaysDestinationNameOrSavedCodeAcrossBothEntrypoints() throws Exception {
|
||||
var ds = new JdbcDataSource();
|
||||
ds.setURL("jdbc:h2:mem:calendar-port-display;MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
|
||||
sql.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR," +
|
||||
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_port_code VARCHAR,destination_warehouse_code VARCHAR," +
|
||||
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
|
||||
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
|
||||
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
|
||||
sql.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,actual_at TIMESTAMP)");
|
||||
sql.execute("CREATE TABLE fl_outbound_dispatch(container_booking_id BIGINT,confirmed_at TIMESTAMP,reversed_at TIMESTAMP,status VARCHAR)");
|
||||
sql.execute("INSERT INTO fl_container_booking(id,destination_port_name,destination_port_code,record_status) VALUES " +
|
||||
"(1,'洛杉矶','USLAX','ACTIVE'),(2,NULL,'USLAX','ACTIVE'),(3,' ','USLAX','ACTIVE')," +
|
||||
"(4,'洛杉矶',NULL,'ACTIVE'),(5,'',' ','ACTIVE')");
|
||||
sql.execute("INSERT INTO fl_outbound_dispatch SELECT id,'2026-10-01 00:00:00',NULL,'WAITING_FOR_LOADING' FROM fl_container_booking");
|
||||
}
|
||||
var config = new MybatisConfiguration();
|
||||
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
|
||||
config.addMapper(ContainerCalendarSourceMapper.class);
|
||||
try (var session = new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
|
||||
var mapper = session.getMapper(ContainerCalendarSourceMapper.class);
|
||||
var from = LocalDateTime.parse("2026-10-01T00:00:00");
|
||||
var calendar = mapper.sources(from, from.minusDays(2), from.minusDays(4), from.plusMonths(1));
|
||||
var explicit = mapper.byIds(java.util.List.of(1L, 2L, 3L, 4L, 5L));
|
||||
var expected = java.util.Arrays.asList("洛杉矶", "USLAX", "USLAX", "洛杉矶", null);
|
||||
assertEquals(expected, calendar.stream().map(row -> row.destinationPortName()).toList());
|
||||
for (var row : explicit) {
|
||||
assertEquals(expected.get(row.containerBookingId().intValue() - 1), row.destinationPortName());
|
||||
}
|
||||
assertEquals(5, explicit.size());
|
||||
}
|
||||
}
|
||||
|
||||
/** 后续观测可将实际到港修正为更早日期,两入口须取同一条最新实际记录。 */
|
||||
@Test
|
||||
void usesLatestObservedArrivalIncludingSameObservationIdTieBreakAcrossBothEntrypoints() throws Exception {
|
||||
@@ -53,7 +18,7 @@ class ContainerCalendarSourceMapperTest {
|
||||
ds.setURL("jdbc:h2:mem:arrival-source-correction;MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
|
||||
sql.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,cabinet_no VARCHAR,container_no VARCHAR," +
|
||||
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_port_code VARCHAR,destination_warehouse_code VARCHAR," +
|
||||
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," +
|
||||
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
|
||||
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
|
||||
sql.execute("CREATE TABLE fl_carrier_schedule_query(id BIGINT,version INT,status VARCHAR)");
|
||||
@@ -100,7 +65,7 @@ class ContainerCalendarSourceMapperTest {
|
||||
ds.setURL("jdbc:h2:mem:four-type-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
|
||||
sql.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR," +
|
||||
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_port_code VARCHAR,destination_warehouse_code VARCHAR," +
|
||||
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," +
|
||||
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
|
||||
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
|
||||
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/` |
|
||||
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` |
|
||||
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` |
|
||||
| 内部散板需求只读来源 | `dispatch/service/DispatchCapacityDemandService.java`、`DispatchQueryController.java`的`/dispatches/capacity-demands`;发布字段仅随智能排车创建保存且缺省不发布,编辑命令不接收该字段;重发须按原门禁取消排车再重新创建,当前业务口径见拼车主契约,不恢复旧车次人工发布入口 |
|
||||
| 内部散板需求只读来源 | `dispatch/service/DispatchCapacityDemandService.java`、`DispatchQueryController.java`的`/dispatches/capacity-demands`;默认发布字段随原排车创建/编辑保存,当前业务口径见拼车主契约,不恢复旧车次人工发布入口 |
|
||||
| 平台排车查询、整车四块详情、审核与实载 | `dispatch/controller/DispatchQueryController.java`、`DispatchAggregateController.java`;公开契约`dispatch/port/`,业务规则见`../../docs/海外仓业务/排车列表统一作业改造契约.md` |
|
||||
| 草稿、双端装车发运 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`;自动发运编排`dispatch/service/DispatchLoadingDepartureService.java` |
|
||||
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` |
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
退货预报、签收、库存、合箱与退货出库:
|
||||
|
||||
- 客户预报 `returnreceipt/controller/MerchantReturnOrderController.java` → `returnreceipt/service/ReturnReceiptService.java`,路径 `/api/merchant/v1/last-leg/return-orders`;客户范围取认证身份,预报按 SKU 保存期望数量。状态 `PENDING_RECEIPT → PARTIALLY_RECEIVED → COMPLETED` 表示签收进度,不等于换标或出库完成。
|
||||
- PDA 后端签收 `returnreceipt/controller/PdaReturnReceiptController.java`,前缀 `/api/pda/v1/last-leg/return-receipts`,依次 `start/sku-candidates/confirm`;按冻结仓和退货单校验实收,不允许未预报 SKU 或超量。确认必须传提交前全单累计实收量 `expectedReceivedQuantity`,在订单锁内校验;响应丢失后换新键重复提交旧快照返回冲突,刷新后重新操作,同键幂等请求不得重复入账。确认同时生成签收箱与 SKU 库存;设备页面是否接通另查 PDA 工作台,不能以 Controller 存在代替前端接入。
|
||||
- 库存/箱号 `returnreceipt/controller/PlatformReturnInventoryController.java`、`MerchantReturnInventoryController.java` → `returnreceipt/service/ReturnInventoryService.java`,前缀分别为 `/api/platform/v1/last-leg/return-inventory`、`/api/merchant/v1/last-leg/return-inventory`;PDA 合箱入口 `PdaReturnInventoryController.java` 的 `/packing/orders`、`/cartons/merge`、`/cartons/{cartonMark}/label`。同仓同客户选取退货单及可用 SKU,合箱扣减来源箱数量并生成目标箱,空来源箱标为 `MERGED`,不得跨客户或超出可用量。实物合箱 `/cartons/merge-boxes` 必须提交扫码时全部来源箱 SKU 数量 `expectedSourceQuantities`,来源行锁内核对完整快照;旧快照换新键也拒绝扣减,重新扫描后才能提交当前实物。内部退货换标调用从已锁定任务及来源库存生成快照。
|
||||
- PDA 后端签收 `returnreceipt/controller/PdaReturnReceiptController.java`,前缀 `/api/pda/v1/last-leg/return-receipts`,依次 `start/sku-candidates/confirm`;按冻结仓和退货单校验实收,不允许未预报 SKU 或超量,幂等重放不得重复入账。确认同时生成签收箱与 SKU 库存;设备页面是否接通另查 PDA 工作台,不能以 Controller 存在代替前端接入。
|
||||
- 库存/箱号 `returnreceipt/controller/PlatformReturnInventoryController.java`、`MerchantReturnInventoryController.java` → `returnreceipt/service/ReturnInventoryService.java`,前缀分别为 `/api/platform/v1/last-leg/return-inventory`、`/api/merchant/v1/last-leg/return-inventory`;PDA 合箱入口 `PdaReturnInventoryController.java` 的 `/packing/orders`、`/cartons/merge`、`/cartons/{cartonMark}/label`。同仓同客户选取退货单及可用 SKU,合箱扣减来源箱数量并生成目标箱,空来源箱标为 `MERGED`,不得跨客户或超出可用量。
|
||||
- 退货出库继续进入 `delivery/service/DeliveryTransferService.java` 的 `RETURN_OUTBOUND`,按退货库存可用箱唛创建与拣货;不能与 `exceptionorder/` 的派送退回恢复库存混用。契约见 `src/main/resources/openapi/overseas-business-v1.yaml`,表入口 `src/main/resources/db/lastleg.sql` 的 `ll_return_*`。
|
||||
- 客户箱号管理使用 `GET /api/merchant/v1/last-leg/return-inventory/cartons` 按实物箱分页,库存箱内 SKU 明细仍使用根查询;两者均按登录客户精确隔离。合箱 `sourceBatchNo/sourceReturnNo` 保存实际消耗来源的去重业务编号(多个以逗号分隔),不得写状态或包含未取货订单。`ReturnCartonBarcode` 按箱号生成 Code 128 SVG,打印内容不得使用内部客户/仓库 ID。
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
| 托盘分配算法 | `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`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 |
|
||||
| FBA收货短少确认 | `inboundplan/controller/InboundPlanController.java` 的 `/{planNo}/confirm-shortage` → `InboundPdaWorkflowService.confirmShortage`;仅平台入库编辑权限指定客户操作,要求待差异核对、原版本及应收/实收快照、原因和幂等键。仅同柜次同目的仓收货组单票且冻结申报单SKU可按总数确认,多SKU及跨票自动分摊拒绝;逐SKU实收不得超原申报,确认后保留应收与现场结束时间,按实收生成库存和原计租批次,计划/PDA任务进入 `PARTIAL_INBOUND`,尾程订单完成入库;同事务交接原预排,明确零实收的托盘同步移除预排明细、释放本车占用及预约分配并重算整车货量;缺少零实收证据或已有拣货拒绝。不重复发布卸柜事件。无实收、超收或逐SKU事实不一致拒绝;空预留托盘不能再次作为计划货源排车。 |
|
||||
| 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` |
|
||||
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并复用 `FILE_SERVICE_URL` 只读访问 File 服务,单图不可用不改变拆柜状态 |
|
||||
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||
@@ -87,7 +86,7 @@
|
||||
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
|
||||
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。
|
||||
- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按无实际拼车货物展示,车辆归属独立,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。
|
||||
- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按实际接入需求池拼车货物展示,车辆归属独立,包含MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五类装车照片(每类最多5张),无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
|
||||
- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按实际接入需求池拼车货物展示,车辆归属独立,包含MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
|
||||
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
|
||||
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
|
||||
|
||||
@@ -162,5 +161,3 @@
|
||||
- 事件契约变化同时检查 `common-event-contract`、直接生产者/消费者、Outbox/Inbox 和 DeliveryAck 的 eventId/payloadHash;按 `../../docs/统一认证与服务通信契约.md` 的逐事件表验证,不引入旧 Payload 兼容。
|
||||
- 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`。
|
||||
- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。
|
||||
|
||||
- 2026-10-26 装车资料:五类依次为司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;既有技术槽位与文件归属保持。每类1至5张,未传 `replaceFileId` 追加,指定当前同类文件ID则精确替换;油耗仍单张替换。五类至少各一张且装车确认后才自动派送,同类多张不能充当其他类别,附加行版本/幂等/历史及当前文件下载授权共同保留。无需新增数据库字段,PDA、Admin、Lastleg需配套发布。
|
||||
|
||||
@@ -264,7 +264,3 @@ node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all -
|
||||
- 两条边均属于同一 `lastleg-service` 部署单元内的海外仓出库与派送正式链路;本次只冻结已存在的生产依赖,不扩大海外主数据或安全投影的允许方向。
|
||||
|
||||
2026-10-03 外接来货使用`FleetBookingIncomingGateway`,仅`transfer.fleet.FleetIncomingAdapter`实现该端口并复用`FleetDispatchGateway`的目的键算法;这两个精确类引用进入reviewedClassDependencies,不放开整个转运切片读取fleet实现。fleet不导入转运DTO/Service/Mapper,转运仍维护实收与库存;车次只保存实收证据和确认的承运载荷。包无环及禁止其他消费者扩大依赖的反向测试继续生效。
|
||||
|
||||
### 档案照片只读复用边界(2026-10-26)
|
||||
|
||||
`DispatchMasterPhotoService`仅通过`fleet.photo.port.FleetResourcePhotoPort`及其照片DTO读取档案照片,再经排车文件网关生成当趟副本。`TransportTaskService`仅新增照片类别DTO引用,不读取照片Mapper或Repository。`fleet.photo`复用车辆`VehicleRepository`应用端口与司机应用服务校验身份,不反向依赖排车;精确边界纳入依赖合同。
|
||||
|
||||
@@ -64,8 +64,6 @@ test('fleet stays a business slice and its policies have no infrastructure depen
|
||||
&& edge.target.startsWith('com.baiyue.lastleg.fleet.'));
|
||||
assert.ok(dispatchEdges.length > 0);
|
||||
assert.ok(dispatchEdges.every(edge => /\.(FleetDispatchGateway|FleetDispatchBindingGuard|FleetTripExecutionService|FleetExecutionModels|FleetCompletionModels)$/.test(edge.target)
|
||||
|| edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchMasterPhotoService'
|
||||
&& ['com.baiyue.lastleg.fleet.photo.port.FleetResourcePhotoPort', 'com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels'].includes(edge.target)
|
||||
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningAdapter'
|
||||
&& /\.(FleetBookingIncomingGateway|FleetDispatchPlanningGateway)$/.test(edge.target)
|
||||
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper'
|
||||
@@ -84,7 +82,6 @@ test('fleet stays a business slice and its policies have no infrastructure depen
|
||||
'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged',
|
||||
'TransportTaskService->business.FleetPoolOrderQueries', 'TransportTaskService->driver.DriverService',
|
||||
'TransportTaskService->reporting.TripOperationsEvidenceGateway',
|
||||
'TransportTaskService->photo.FleetResourcePhotoModels',
|
||||
// 取货照片沿既有运输边界读取车次身份/时间,并复用司机资格与证照授权;不开放 Mapper。
|
||||
'TransportReviewPhotos->driver.DriverService', 'TransportReviewPhotos->trip.FleetTripEntity',
|
||||
].some(reference => `${edge.source}->${edge.target}` === `com.baiyue.lastleg.dispatch.transport.${reference.replace('->', '->com.baiyue.lastleg.fleet.')}`)),
|
||||
|
||||
+1
-7
@@ -16,12 +16,6 @@ public interface AppointmentWorkDispatchPort {
|
||||
record CandidateBatches(List<BatchReference> appointmentBatches,List<String> blockReasons,boolean occupied) { }
|
||||
/** 整页真实客户/计划托盘一次批读;occupied含有效草稿与正式实托占用,由调用方核对本草稿身份。 */
|
||||
Map<CandidatePhysical,CandidateBatches> candidateBatches(List<CandidatePhysical> candidates);
|
||||
/** 只读取本车已选实物的逐票预约选项;本车原占用不扣减本车可重新分配量。 */
|
||||
Map<CandidatePhysical,List<AllocationSource>> allocationOptions(long dispatchId,List<CandidatePhysical> items);
|
||||
record AllocationOption(String batchKey,String batchNo,Long recordVersion,int availablePackages,
|
||||
String confirmationNo,String appointmentAt,String appointmentEndAt,String timezone) { }
|
||||
record AllocationSource(String sourceKey,String orderNo,String inboundCode,String destinationKey,Integer packageCount,
|
||||
List<AllocationOption> options,List<String> blockReasons) { }
|
||||
/** Current original allocations, grouped for authorized dispatches; historical copies are read-only evidence. */
|
||||
java.util.Map<Long,List<BatchReference>> references(List<Long> dispatchIds);
|
||||
/** 在来源锁前按批次键一次性预锁并核对权威版本,版本零合法。 */
|
||||
@@ -33,7 +27,7 @@ public interface AppointmentWorkDispatchPort {
|
||||
String departureBlockReason(String dispatchNo);
|
||||
record ReceivingWindow(String batchKey,String batchNo,String customerId,String destinationKey,String confirmationNo,
|
||||
java.time.Instant startAt,java.time.Instant endAt,String agreementKey,int version) { }
|
||||
/** FBA选入前须已有正式时间确认;提交审核、审核通过和开始拣货继续复核运输安排。 */
|
||||
/** 预排不受限;提交审核、审核通过和开始拣货须有真实收货确认及可执行时间。 */
|
||||
String reviewBlockReason(String dispatchNo,java.time.Instant plannedDepartureAt,java.time.Instant estimatedArrivalAt);
|
||||
List<ReceivingWindow> reviewEvidence(String dispatchNo);
|
||||
void removeItem(long dispatchId,long itemId);
|
||||
|
||||
+2
-12
@@ -59,26 +59,16 @@ public class DeliveryCargoGroups {
|
||||
var rows=entry.getValue().stream().sorted(Comparator.comparing(CargoLine::dispatchItemId)
|
||||
.thenComparing(CargoLine::attributionId)).toList();
|
||||
var first=rows.get(0);
|
||||
// FBA以真实仓码分组;外接来货可缺内部仓ID,只沿同组已知ID核对,不能覆盖冲突值。
|
||||
var destination=rows.stream().map(CargoLine::destination)
|
||||
.filter(value->!blank(value.warehouseId())).findFirst().orElse(first.destination());
|
||||
if (rows.stream().anyMatch(row->!Objects.equals(withKnownFbaWarehouseId(row.destination(),destination.warehouseId()),destination)))
|
||||
if (rows.stream().anyMatch(row->!Objects.equals(row.destination(),first.destination())))
|
||||
throw conflict("同组货物的目的地信息不一致,请核对后安排");
|
||||
long packages=rows.stream().mapToLong(CargoLine::packages).reduce(0,Math::addExact);
|
||||
long pallets=rows.stream().map(CargoLine::warehousePalletId).filter(Objects::nonNull).distinct().count();
|
||||
result.add(new Group(entry.getKey(),first.customerId(),first.customerName(),destination,
|
||||
result.add(new Group(entry.getKey(),first.customerId(),first.customerName(),first.destination(),
|
||||
hash(rows),rows,packages,pallets));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** 仅补同一分组FBA缺失的内部引用,其他目的地事实必须保持完全一致。 */
|
||||
private Destination withKnownFbaWarehouseId(Destination value,String warehouseId) {
|
||||
if (!"FBA".equals(value.type()) || !blank(value.warehouseId()) || blank(warehouseId)) return value;
|
||||
return new Destination(value.key(),value.type(),warehouseId,value.warehouseCode(),value.warehouseName(),
|
||||
value.address(),value.postalCode(),value.contactName(),value.contactPhone());
|
||||
}
|
||||
|
||||
/** 客户是组身份的一部分,同目的仓的其他客户永不合单。 */
|
||||
public String groupKey(String customerId,Destination destination) {
|
||||
return hash(List.of(customerId,destination.key()));
|
||||
|
||||
+4
-3
@@ -78,6 +78,7 @@ 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;
|
||||
import java.math.RoundingMode;
|
||||
@@ -191,7 +192,7 @@ public class DeliveryTransferService {
|
||||
outbound.setVolumeCbm(sum(createdMetricItems, OutboundPalletItem::getVolumeCbm));
|
||||
outbound.setOverseasWarehouseId(selectedWarehouseId); outboundOrders.updateById(outbound);
|
||||
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setOutboundOrderId(outbound.getId());
|
||||
task.setTaskNo(orderNoGenerator.next("OUTBOUND_TASK")); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
|
||||
task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
|
||||
return taskResult(outbound, task);
|
||||
}
|
||||
|
||||
@@ -306,7 +307,7 @@ public class DeliveryTransferService {
|
||||
delivery.setVersion(0L); delivery.setCreatedBy(operatorId); delivery.setCreatedAt(now); delivery.setUpdatedAt(now);
|
||||
deliveryOrders.insert(delivery);
|
||||
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setOutboundOrderId(outbound.getId());
|
||||
task.setTaskNo(orderNoGenerator.next("OUTBOUND_TASK")); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
|
||||
task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
|
||||
return taskResult(outbound, task);
|
||||
}
|
||||
|
||||
@@ -458,7 +459,7 @@ public class DeliveryTransferService {
|
||||
recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "仓储转运出库自动生成派送单", now);
|
||||
|
||||
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId);
|
||||
task.setOutboundOrderId(outbound.getId()); task.setTaskNo(orderNoGenerator.next("OUTBOUND_TASK"));
|
||||
task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId());
|
||||
task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
|
||||
transfer.setStatus("WAIT_OUTBOUND");
|
||||
if ("FBA".equals(destinationType)) transfer.setFbaWarehouseCode(request.destinationWarehouseCode().trim());
|
||||
|
||||
-12
@@ -53,18 +53,6 @@ public class DispatchAggregateController {
|
||||
public ApiResponse<com.baiyue.lastleg.dispatch.port.DispatchWorkModels.Items> items(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
|
||||
return ApiResponse.success(service.items(DispatchActorFactory.platform(user),dispatchNo));
|
||||
}
|
||||
/** 本车货物逐票读取正式预约选项及当前分配。 */
|
||||
@GetMapping("/{dispatchNo}/appointment-allocations")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
public ApiResponse<com.baiyue.lastleg.dispatch.port.DispatchWorkModels.AppointmentAllocations> appointmentAllocations(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
|
||||
return ApiResponse.success(service.appointmentAllocations(DispatchActorFactory.platform(user),dispatchNo));
|
||||
}
|
||||
/** 原车版本化关联正式预约,不新建排车或修改库存。 */
|
||||
@PutMapping("/{dispatchNo}/appointment-allocations")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||
public ApiResponse<CommandResult> appointmentAllocations(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody com.baiyue.lastleg.dispatch.port.DispatchWorkModels.AppointmentAllocationCommand request) {
|
||||
return ApiResponse.success(service.saveAppointmentAllocations(DispatchActorFactory.platform(user),dispatchNo,request));
|
||||
}
|
||||
/** 读取实际起点和卸货顺序。 */
|
||||
@GetMapping("/{dispatchNo}/route")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ public class DispatchAggregate {
|
||||
private String dispatchNo;
|
||||
private String taskType;
|
||||
/** 是否向内部散板需求池发布本车余位。 */
|
||||
private Boolean publishLooseboardDemand = false;
|
||||
private Boolean publishLooseboardDemand = true;
|
||||
private Long tripId;
|
||||
private String overseasWarehouseId;
|
||||
private String overseasWarehouseCode;
|
||||
|
||||
+2
-15
@@ -19,20 +19,7 @@ public interface DispatchAggregateMapper {
|
||||
OR EXISTS(SELECT 1 FROM ll_dispatch_truck_task t WHERE t.dispatch_no=d.dispatch_no AND t.customer_id=#{q.customerId})
|
||||
OR EXISTS(SELECT 1 FROM ll_fleet_pickup_task p JOIN ll_fleet_trip f ON f.trip_no=p.trip_no WHERE f.id=d.trip_id AND p.customer_id=#{q.customerId})
|
||||
)</if>
|
||||
<if test='q.fbaAppointmentNo != null'> AND (
|
||||
EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.id AND i.destination_type='FBA'
|
||||
AND JSON_CONTAINS(COALESCE(JSON_EXTRACT(d.resources_json,'$.maintenance.destinationAppointments'),'[]'),
|
||||
JSON_OBJECT('destinationKey',CONCAT('FBA:',UPPER(TRIM(COALESCE(NULLIF(i.destination_warehouse_code,''),i.fba_warehouse_code)))),'appointmentNo',#{q.fbaAppointmentNo}))=1)
|
||||
OR EXISTS(SELECT 1 FROM ll_dispatch_appointment_allocation a
|
||||
WHERE a.dispatch_id=d.id AND a.batch_key LIKE 'FBA_APPOINTMENT:%' AND
|
||||
""" + DispatchReceivingSql.CONFIRMATION_NO + """
|
||||
=#{q.fbaAppointmentNo}
|
||||
AND NOT EXISTS(SELECT 1 FROM JSON_TABLE(
|
||||
COALESCE(JSON_EXTRACT(d.resources_json,'$.maintenance.destinationAppointments'),'[]'),
|
||||
'$[*]' COLUMNS(destination_key VARCHAR(256) PATH '$.destinationKey',
|
||||
appointment_no VARCHAR(128) PATH '$.appointmentNo' NULL ON EMPTY)) saved
|
||||
WHERE saved.destination_key=a.destination_key AND NULLIF(TRIM(saved.appointment_no),'') IS NOT NULL))
|
||||
)</if>
|
||||
<if test='q.fbaAppointmentNo != null'> AND EXISTS(SELECT 1 FROM ll_dispatch_appointment_allocation a JOIN ll_inbound_fba_appointment ap ON a.batch_key=CONCAT('FBA_APPOINTMENT:',ap.id) WHERE a.dispatch_id=d.id AND ap.confirmation_no=#{q.fbaAppointmentNo})</if>
|
||||
""";
|
||||
String REVIEW_DISPATCH = "CASE d.review_state WHEN 'DRAFT' THEN 'NOT_SUBMITTED' WHEN 'DISPATCH_PENDING' THEN 'PENDING' WHEN 'DISPATCH_REJECTED' THEN 'REJECTED' ELSE 'APPROVED' END";
|
||||
String REVIEW_LOADING = "CASE d.review_state WHEN 'LOADING_PENDING' THEN 'PENDING' WHEN 'LOADING_REJECTED' THEN 'REJECTED' WHEN 'APPROVED' THEN 'APPROVED' ELSE 'NOT_SUBMITTED' END";
|
||||
@@ -122,7 +109,7 @@ public interface DispatchAggregateMapper {
|
||||
estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt},
|
||||
loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey},
|
||||
arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt},
|
||||
delivery_completed_at=#{r.deliveryCompletedAt},item_count=#{r.itemCount},route=#{r.route},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount},
|
||||
delivery_completed_at=#{r.deliveryCompletedAt},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount},
|
||||
shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt}
|
||||
WHERE id=#{r.id} AND record_version=#{version}
|
||||
""")
|
||||
|
||||
+3
-5
@@ -39,7 +39,7 @@ public interface DispatchBolMapper {
|
||||
LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id
|
||||
LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=di.customer_id
|
||||
AND di.return_receipt_id IS NULL AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER'
|
||||
LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id AND l.plan_id=a.plan_id
|
||||
LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id AND l.plan_id=p.plan_id
|
||||
LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id
|
||||
LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=l.customer_id
|
||||
WHERE di.dispatch_order_id=#{dispatchOrderId}
|
||||
@@ -49,13 +49,11 @@ public interface DispatchBolMapper {
|
||||
""")
|
||||
List<Cargo> findWholeDispatch(@Param("dispatchOrderId") long dispatchOrderId);
|
||||
|
||||
/** 原batch+客户+稳定来源+目的地定位货物,编号取当前客户收货确认;BOOKING保留持久化order_id。 */
|
||||
/** 原batch+客户+稳定来源+目的地定位预约货物;BOOKING使用持久化order_id,绝不自行拼ORDER键。 */
|
||||
@Select("""
|
||||
SELECT a.dispatch_item_id AS dispatchItemId,a.plan_pallet_id AS planPalletId,a.batch_key AS batchKey,
|
||||
a.customer_id AS customerId,a.source_key AS sourceKey,a.destination_key AS destinationKey,
|
||||
a.package_count AS packageCount,p.id AS appointmentId,i.order_id AS orderId,
|
||||
""" + DispatchReceivingSql.CONFIRMATION_NO + """
|
||||
AS confirmationNo
|
||||
a.package_count AS packageCount,p.id AS appointmentId,i.order_id AS orderId,p.confirmation_no AS confirmationNo
|
||||
FROM ll_dispatch_appointment_allocation a
|
||||
LEFT JOIN ll_inbound_fba_appointment p ON a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id)
|
||||
LEFT JOIN ll_inbound_fba_appointment_item i ON i.appointment_id=p.id AND i.customer_id=a.customer_id
|
||||
|
||||
+8
-61
@@ -11,8 +11,6 @@ import java.util.List;
|
||||
public interface DispatchCandidateMapper {
|
||||
/** 自动预留托盘不是实收;尚未开始收货时仍以计划来源预排,不生成库存或实托装载事实。 */
|
||||
String PLAN_ONLY = "(p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0))";
|
||||
/** 完成入库或已确认短少后,不再把未实收的预留托盘当作在途货;真实库存仍可排车。 */
|
||||
String OPEN_PLAN = "COALESCE(ip.status,'') NOT IN ('COMPLETED','PARTIAL_INBOUND')";
|
||||
/** 在途计划的全部真实来源必须已离港;人工节点覆盖后不再借用原柜节点。 */
|
||||
String DEPARTED_PLAN =
|
||||
"EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " +
|
||||
@@ -42,55 +40,6 @@ public interface DispatchCandidateMapper {
|
||||
"COALESCE((SELECT CASE WHEN COUNT(*)>0 AND COUNT(*)=COUNT(d.gross_weight_kg) AND MIN(d.gross_weight_kg)>0 " +
|
||||
"THEN SUM(d.gross_weight_kg) END FROM ll_order_detail d WHERE d.customer_id=ip.customer_id AND d.order_id=l.order_id)," +
|
||||
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound WHERE inbound.id=l.order_id AND inbound.customer_id=ip.customer_id LIMIT 1))";
|
||||
/** 收货毛重按逐票确认箱数分摊;零贡献不占重量,任一有效来源缺失毛重时整托保持未知。 */
|
||||
String RECEIVED_GROSS_WEIGHT =
|
||||
"CASE WHEN MIN(CASE WHEN a.counted_package_count=0 THEN 1 WHEN " + SOURCE_GROSS_WEIGHT +
|
||||
" >0 AND l.required_package_count>0 AND a.counted_package_count>0 THEN 1 ELSE 0 END)=1 THEN " +
|
||||
"ROUND(SUM(CASE WHEN a.counted_package_count=0 THEN 0 ELSE " + SOURCE_GROSS_WEIGHT +
|
||||
" * a.counted_package_count / NULLIF(l.required_package_count,0) END),3) END";
|
||||
/** 体积采用收货端记录的逐来源体积,不将计划均分值或部分来源合计冒充完整收货量。 */
|
||||
String RECEIVED_VOLUME =
|
||||
"CASE WHEN MIN(CASE WHEN a.counted_package_count=0 OR a.actual_volume_cbm>0 THEN 1 ELSE 0 END)=1 " +
|
||||
"THEN NULLIF(SUM(CASE WHEN a.counted_package_count=0 THEN 0 ELSE a.actual_volume_cbm END),0) END";
|
||||
/** 已收货候选与箱数共用当前库存范围;入仓身份沿余额关联,不能把尾程订单ID当转运入仓ID。 */
|
||||
String CURRENT_STOCK_FROM =
|
||||
" FROM ll_inventory_position stock LEFT JOIN ll_inventory_balance balance " +
|
||||
"ON balance.id=stock.balance_id AND balance.customer_id=stock.customer_id " +
|
||||
"LEFT JOIN ll_inbound_order inbound ON inbound.id=balance.inbound_order_id AND inbound.customer_id=stock.customer_id " +
|
||||
"WHERE stock.customer_id=ip.customer_id AND stock.pallet_id=p.warehouse_pallet_id AND stock.available_packages>0";
|
||||
String CURRENT_SOURCE_WEIGHT =
|
||||
"COALESCE((SELECT CASE WHEN COUNT(*)>0 AND COUNT(*)=COUNT(d.gross_weight_kg) AND MIN(d.gross_weight_kg)>0 " +
|
||||
"THEN SUM(d.gross_weight_kg) END FROM ll_order_detail d WHERE d.customer_id=stock.customer_id AND d.order_id=inbound.id),inbound.gross_weight_kg)";
|
||||
/** 拆合托和部分出库后的估算按各真实入仓来源剩余箱数汇总,任一来源不明时不返回部分合计。 */
|
||||
String CURRENT_STOCK_WEIGHT =
|
||||
"(SELECT CASE WHEN MIN(CASE WHEN inbound.package_count>0 AND " + CURRENT_SOURCE_WEIGHT +
|
||||
" >0 THEN 1 ELSE 0 END)=1 THEN ROUND(SUM(" + CURRENT_SOURCE_WEIGHT +
|
||||
" * stock.available_packages / NULLIF(inbound.package_count,0)),3) END" + CURRENT_STOCK_FROM + ")";
|
||||
String CURRENT_STOCK_VOLUME =
|
||||
"(SELECT CASE WHEN MIN(CASE WHEN inbound.package_count>0 AND inbound.volume_cbm>0 THEN 1 ELSE 0 END)=1 " +
|
||||
"THEN ROUND(SUM(inbound.volume_cbm * stock.available_packages / NULLIF(inbound.package_count,0)),4) END" + CURRENT_STOCK_FROM + ")";
|
||||
/** 仅当前整托仍为同一转运来源、全量可用且未拆合换绑时,保留该托实测;不按订单总重均摊已有实测。 */
|
||||
String CURRENT_MEASUREMENT_FROM =
|
||||
" FROM ll_warehouse_transfer_pallet measurement JOIN ll_warehouse_transfer_order transfer " +
|
||||
"ON transfer.id=measurement.transfer_order_id AND transfer.customer_id=measurement.customer_id " +
|
||||
"WHERE measurement.customer_id=ip.customer_id AND measurement.warehouse_pallet_id=p.warehouse_pallet_id " +
|
||||
"AND transfer.lastleg_order_id=ip.source_order_id AND measurement.status='COMPLETED' AND measurement.completed_at IS NOT NULL " +
|
||||
"AND measurement.carton_count>0 AND measurement.carton_count=(SELECT SUM(stock.available_packages) FROM ll_inventory_position stock " +
|
||||
"WHERE stock.customer_id=ip.customer_id AND stock.pallet_id=p.warehouse_pallet_id) " +
|
||||
"AND NOT EXISTS(SELECT 1 FROM ll_inventory_position stock LEFT JOIN ll_inventory_balance balance " +
|
||||
"ON balance.id=stock.balance_id AND balance.customer_id=stock.customer_id " +
|
||||
"WHERE stock.pallet_id=p.warehouse_pallet_id AND (stock.available_packages+stock.hold_packages+stock.allocated_packages+stock.picked_packages+stock.receiving_packages)>0 " +
|
||||
"AND (stock.customer_id!=ip.customer_id OR balance.inbound_order_id IS NULL OR balance.inbound_order_id!=transfer.inbound_order_id " +
|
||||
"OR stock.overseas_warehouse_id!=transfer.operating_warehouse_id OR stock.hold_packages+stock.allocated_packages+stock.picked_packages+stock.receiving_packages>0)) " +
|
||||
"AND NOT EXISTS(SELECT 1 FROM ll_inventory_ledger movement WHERE movement.pallet_id=p.warehouse_pallet_id " +
|
||||
"AND movement.created_at>=measurement.completed_at AND movement.business_type IN " +
|
||||
"('RELABEL_PALLET_DISASSEMBLE','PALLET_ASSEMBLE','PALLET_DISASSEMBLE','PALLET_SWAP','PALLET_UNBIND'))";
|
||||
String CURRENT_GROSS_WEIGHT =
|
||||
"COALESCE((SELECT CASE WHEN COUNT(*)=1 AND MAX(measurement.gross_weight_kg)>0 THEN MAX(measurement.gross_weight_kg) END" +
|
||||
CURRENT_MEASUREMENT_FROM + ")," + CURRENT_STOCK_WEIGHT + ")";
|
||||
String CURRENT_VOLUME =
|
||||
"COALESCE((SELECT CASE WHEN COUNT(*)=1 AND MAX(measurement.volume_cbm)>0 THEN MAX(measurement.volume_cbm) END" +
|
||||
CURRENT_MEASUREMENT_FROM + ")," + CURRENT_STOCK_VOLUME + ")";
|
||||
@Select("<script>" +
|
||||
"SELECT DISTINCT customer_id FROM ll_inbound_plan WHERE id IN " +
|
||||
"<foreach collection='planIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||
@@ -111,11 +60,11 @@ public interface DispatchCandidateMapper {
|
||||
"MIN(l.order_id) AS orderId," +
|
||||
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
|
||||
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
|
||||
"CASE WHEN NOT " + PLAN_ONLY + " THEN " + CURRENT_GROSS_WEIGHT + " ELSE CASE WHEN MIN(CASE WHEN " + SOURCE_GROSS_WEIGHT +
|
||||
"CASE WHEN MIN(CASE WHEN " + SOURCE_GROSS_WEIGHT +
|
||||
" >0 AND (COALESCE(l.volume_cbm,0)<=0 OR COALESCE(a.allocated_volume_cbm,0)>0) THEN 1 ELSE 0 END)=1 THEN " +
|
||||
"NULLIF(SUM(CASE WHEN COALESCE(l.volume_cbm,0)>0 THEN " + SOURCE_GROSS_WEIGHT +
|
||||
" * a.allocated_volume_cbm / l.volume_cbm ELSE " + SOURCE_GROSS_WEIGHT +
|
||||
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END END AS grossWeightKg," +
|
||||
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
|
||||
"MIN(ip.container_no) AS containerNo," +
|
||||
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
|
||||
// 仓ID文本统一保留128字符,避免混合CASE的NONE排序规则及无长度CHAR截断;来源优先级与身份等值不变。
|
||||
@@ -139,8 +88,7 @@ public interface DispatchCandidateMapper {
|
||||
"CASE WHEN NOT " + PLAN_ONLY + " THEN COALESCE((SELECT SUM(pos.available_packages) " +
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id),0) " +
|
||||
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " +
|
||||
"ELSE a.planned_package_count END),0) END AS packageCount,CASE WHEN " + PLAN_ONLY +
|
||||
" THEN p.allocated_volume_cbm ELSE " + CURRENT_VOLUME + " END AS volumeCbm," +
|
||||
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
|
||||
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
|
||||
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
|
||||
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
|
||||
@@ -180,7 +128,7 @@ public interface DispatchCandidateMapper {
|
||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
||||
"AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " +
|
||||
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
|
||||
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
|
||||
@@ -222,11 +170,11 @@ public interface DispatchCandidateMapper {
|
||||
"MIN(l.order_id) AS orderId," +
|
||||
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
|
||||
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
|
||||
"CASE WHEN NOT " + PLAN_ONLY + " THEN " + CURRENT_GROSS_WEIGHT + " ELSE CASE WHEN MIN(CASE WHEN " + SOURCE_GROSS_WEIGHT +
|
||||
"CASE WHEN MIN(CASE WHEN " + SOURCE_GROSS_WEIGHT +
|
||||
" >0 AND (COALESCE(l.volume_cbm,0)<=0 OR COALESCE(a.allocated_volume_cbm,0)>0) THEN 1 ELSE 0 END)=1 THEN " +
|
||||
"NULLIF(SUM(CASE WHEN COALESCE(l.volume_cbm,0)>0 THEN " + SOURCE_GROSS_WEIGHT +
|
||||
" * a.allocated_volume_cbm / l.volume_cbm ELSE " + SOURCE_GROSS_WEIGHT +
|
||||
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END END AS grossWeightKg," +
|
||||
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
|
||||
"MIN(ip.container_no) AS containerNo," +
|
||||
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
|
||||
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||
@@ -249,8 +197,7 @@ public interface DispatchCandidateMapper {
|
||||
"CASE WHEN NOT " + PLAN_ONLY + " THEN COALESCE((SELECT SUM(pos.available_packages) " +
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
|
||||
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " +
|
||||
"ELSE a.planned_package_count END),0) END AS packageCount,CASE WHEN " + PLAN_ONLY +
|
||||
" THEN p.allocated_volume_cbm ELSE " + CURRENT_VOLUME + " END AS volumeCbm," +
|
||||
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
|
||||
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
|
||||
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
|
||||
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
|
||||
@@ -285,7 +232,7 @@ public interface DispatchCandidateMapper {
|
||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
||||
"AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " +
|
||||
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
|
||||
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
||||
|
||||
-16
@@ -11,22 +11,6 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchItemMapper extends BaseMapper<DispatchItem> {
|
||||
/** 收货只定位原计划已关联的有效排车,按车辆父锁顺序返回。 */
|
||||
@Select("<script>SELECT DISTINCT d.dispatch_no,d.trip_id,d.id FROM ll_dispatch_order d "
|
||||
+ "JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id "
|
||||
+ "WHERE d.status='ACTIVE' AND i.plan_id IN "
|
||||
+ "<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> "
|
||||
+ "ORDER BY d.trip_id,d.id</script>")
|
||||
List<ReceiptDispatch> receiptDispatches(@Param("ids") List<Long> planIds);
|
||||
|
||||
record ReceiptDispatch(String dispatchNo, Long tripId, Long id) { }
|
||||
|
||||
/** 逐托交接数量与收货货量;显式写入NULL,避免未知毛重/体积继续保留旧计划值。 */
|
||||
@Update("UPDATE ll_dispatch_item SET warehouse_pallet_id=#{warehousePalletId},pallet_no=#{palletNo},"
|
||||
+ "source_stage=#{sourceStage},package_count=#{packageCount},gross_weight_kg=#{grossWeightKg},volume_cbm=#{volumeCbm} "
|
||||
+ "WHERE id=#{id} AND customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} AND warehouse_pallet_id IS NULL")
|
||||
int confirmReceipt(DispatchItem item);
|
||||
|
||||
/** 平台整车读取全客户明细,后续库存与费用仍按每条真实客户执行。 */
|
||||
@Select("SELECT * FROM ll_dispatch_item WHERE dispatch_order_id=#{dispatchOrderId} ORDER BY customer_id,id")
|
||||
List<DispatchItem> findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId);
|
||||
|
||||
-18
@@ -55,24 +55,6 @@ public interface DispatchReadMapper {
|
||||
""")
|
||||
List<BatchRow> batches(@Param("id") long dispatchId);
|
||||
|
||||
record CargoShipment(String customerId, String destinationKey, String shipmentNo) { }
|
||||
/** 逐票实际货物决定货件号;手填预约号或没有正式批次引用也能读取,客户严格随来源关联。 */
|
||||
@Select("""
|
||||
SELECT DISTINCT i.customer_id AS customerId,
|
||||
CONCAT('FBA:',UPPER(TRIM(COALESCE(NULLIF(TRIM(l.fba_warehouse_code),''),NULLIF(TRIM(i.destination_warehouse_code),''),NULLIF(TRIM(i.fba_warehouse_code),''))))) AS destinationKey,
|
||||
CASE WHEN o.order_type='WAREHOUSE_TRANSFER' THEN wt.fba_shipment_no ELSE d.shipment_no END AS shipmentNo
|
||||
FROM ll_dispatch_item i
|
||||
LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=i.plan_pallet_id AND a.customer_id=i.customer_id
|
||||
AND i.return_receipt_id IS NULL AND COALESCE(i.source_business_type,'')<>'RELABEL_ORDER'
|
||||
LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=i.customer_id
|
||||
JOIN ll_order o ON o.id=CASE WHEN l.id IS NULL THEN i.order_id ELSE l.order_id END AND o.customer_id=i.customer_id
|
||||
LEFT JOIN ll_order_detail d ON d.order_id=o.id AND d.customer_id=o.customer_id
|
||||
AND UPPER(TRIM(d.destination_warehouse_code))=UPPER(TRIM(COALESCE(NULLIF(TRIM(l.fba_warehouse_code),''),NULLIF(TRIM(i.destination_warehouse_code),''),NULLIF(TRIM(i.fba_warehouse_code),''))))
|
||||
LEFT JOIN ll_warehouse_transfer_order wt ON wt.lastleg_order_id=o.id AND wt.customer_id=o.customer_id
|
||||
WHERE i.dispatch_order_id=#{id} AND i.destination_type='FBA'
|
||||
""")
|
||||
List<CargoShipment> cargoShipmentNumbers(@Param("id") long dispatchId);
|
||||
|
||||
/** 未接通跨客户批次写提供者前必须阻断静默丢批次,不能写无依据运输。 */
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id
|
||||
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
|
||||
/** 排车列表和BOL共用当前客户收货确认;调用方的a必须是逐票预约分配。 */
|
||||
final class DispatchReceivingSql {
|
||||
/** 只读当前有效覆盖,不使用多客户批次头汇总值,历史退出记录不能提供编号。 */
|
||||
static final String CONFIRMATION_NO = """
|
||||
(SELECT rg.confirmation_no
|
||||
FROM ll_fba_receiving_member rm
|
||||
JOIN ll_fba_receiving_agreement rg ON rg.agreement_key=rm.agreement_key
|
||||
WHERE rm.batch_key=a.batch_key AND rm.customer_id=a.customer_id AND rm.active_ref IS NOT NULL
|
||||
AND rg.destination_key=a.destination_key AND rg.status='ACTIVE'
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(rg.snapshot_json,'$.details.appointmentStatus'))='SUCCESS'
|
||||
AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(rg.snapshot_json,'$.details.appointmentAt')),'null') IS NOT NULL)
|
||||
""";
|
||||
|
||||
/** 仅提供Mapper编译期SQL片段。 */
|
||||
private DispatchReceivingSql() { }
|
||||
}
|
||||
+1
-38
@@ -21,43 +21,6 @@ public interface DispatchSourceMapper {
|
||||
record OrderReference(Long orderId,String customerId) { }
|
||||
record ItemPicked(Long itemId,long packages) { }
|
||||
|
||||
/** 收货交接只读已确认逐票实收,不以可用库存、出货资格或计划均分反推箱数。 */
|
||||
@Select("<script>" + """
|
||||
SELECT p.id AS planPalletId,p.plan_id AS planId,p.customer_id AS customerId,
|
||||
p.warehouse_pallet_id AS warehousePalletId,p.pallet_no AS palletNo,
|
||||
CAST(ip.overseas_warehouse_id AS CHAR(128)) AS overseasWarehouseId,
|
||||
MIN(l.destination_type) AS destinationType,MIN(l.fba_warehouse_code) AS fbaWarehouseCode,
|
||||
p.delivery_address AS deliveryAddress,SUM(a.counted_package_count) AS packageCount,'RECEIVED' AS sourceStage,
|
||||
""" + DispatchCandidateMapper.RECEIVED_GROSS_WEIGHT + " AS grossWeightKg,"
|
||||
+ DispatchCandidateMapper.RECEIVED_VOLUME + " AS volumeCbm " + """
|
||||
FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id
|
||||
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
|
||||
JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
|
||||
WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND') AND p.warehouse_pallet_id IS NOT NULL
|
||||
GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address
|
||||
HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1
|
||||
AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1
|
||||
""" + "</script>")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> confirmedPlan(@Param("customer") String customer,@Param("id") long id);
|
||||
|
||||
/** 只有原客户、原计划、原仓已结束且逐项明确为零的来源可移除;缺记录不代表短少。 */
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM ll_inbound_plan_pallet p
|
||||
JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id
|
||||
WHERE p.id=#{id} AND p.customer_id=#{customer} AND p.plan_id=#{plan}
|
||||
AND CAST(ip.overseas_warehouse_id AS CHAR(128))=#{warehouse}
|
||||
AND ip.status IN ('COMPLETED','PARTIAL_INBOUND')
|
||||
AND COALESCE(p.actual_package_count,0)=0
|
||||
AND EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id)
|
||||
AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id
|
||||
AND (a.counted_package_count IS NULL OR a.counted_package_count<>0 OR a.picked_package_count IS NULL OR a.picked_package_count<>0))
|
||||
AND NOT EXISTS (SELECT 1 FROM ll_inbound_stock_receipt r JOIN ll_inbound_plan_pallet_allocation a ON a.id=r.allocation_id AND a.customer_id=r.customer_id
|
||||
WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id)
|
||||
""")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
int confirmedEmptyPlan(@Param("customer") String customer,@Param("id") long id,@Param("plan") long plan,@Param("warehouse") String warehouse);
|
||||
|
||||
/** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */
|
||||
@Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p "
|
||||
+ "ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{dispatch} GROUP BY i.id")
|
||||
@@ -132,7 +95,7 @@ public interface DispatchSourceMapper {
|
||||
|
||||
/** 原计划完成实收后才能把预排估算替换为实际整托数量,避免收货中提前锁定半托。 */
|
||||
@Select("SELECT COUNT(*) FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id "
|
||||
+ "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND','PENDING_RECONCILIATION')")
|
||||
+ "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PENDING_RECONCILIATION')")
|
||||
int receivedPlanCompleted(@Param("customer") String customer,@Param("id") Long id);
|
||||
|
||||
/** 退回来源仍使用本票实际实收,不借普通入仓别名。 */
|
||||
|
||||
+4
-4
@@ -24,15 +24,15 @@ public final class DispatchCommands {
|
||||
public record Create(String idempotencyKey, String warehouseId, Vehicle vehicle, Driver driver,
|
||||
Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark,
|
||||
List<CargoSelection> cargo, List<BatchAllocation> batchAllocations, Boolean publishLooseboardDemand) {
|
||||
/** 仅智能排车建单时显式选择发布,未选择时不发布。 */
|
||||
public Create { publishLooseboardDemand = Boolean.TRUE.equals(publishLooseboardDemand); }
|
||||
/** 新建排车默认发布内部余位,显式取消时保持不发布。 */
|
||||
public Create { publishLooseboardDemand = publishLooseboardDemand == null ? true : publishLooseboardDemand; }
|
||||
}
|
||||
|
||||
public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver,
|
||||
Instant plannedDepartureAt, Instant estimatedArrivalAt,
|
||||
String remark, Maintenance maintenance) {
|
||||
String remark, Boolean publishLooseboardDemand, Maintenance maintenance) {
|
||||
/** 缺版本与有效版本零必须区分。 */
|
||||
public Update { recordVersion = requireVersion(recordVersion); }
|
||||
public Update { recordVersion = requireVersion(recordVersion); if (publishLooseboardDemand == null) throw new IllegalArgumentException("请明确是否发布散板需求"); }
|
||||
}
|
||||
|
||||
public record AddCargo(Long recordVersion, String idempotencyKey, List<CargoSelection> cargo,
|
||||
|
||||
+2
-17
@@ -49,7 +49,7 @@ public final class DispatchContracts {
|
||||
@JsonDeserialize(using = DispatchMaintenanceDateDeserializer.class)
|
||||
LocalDate plannedDeliveryDate, String deliveryMethod, String pickupMethod,
|
||||
String loadType, String fleetEmail, String vehicleType,
|
||||
String appointmentAccount, List<DestinationAppointment> destinationAppointments) {
|
||||
String appointmentAccount, String appointmentNo) {
|
||||
/** 空白统一为空;只约束已填写的选项及长度,不阻止空草稿保存。 */
|
||||
public Maintenance {
|
||||
deliveryMethod = option(deliveryMethod, "派送方式", "FBA自提", "客户自提", "卡派打托", "卡派地板", "快递", "外配");
|
||||
@@ -58,10 +58,7 @@ public final class DispatchContracts {
|
||||
vehicleType = option(vehicleType, "车辆类型", "厢式货车", "平板车", "集装箱卡车");
|
||||
fleetEmail = limited(fleetEmail, "车队邮箱", 254);
|
||||
appointmentAccount = limited(appointmentAccount, "预约账号", 128);
|
||||
if (destinationAppointments != null && destinationAppointments.stream().anyMatch(java.util.Objects::isNull)) throw new IllegalArgumentException("预约目的仓不能空缺");
|
||||
destinationAppointments = destinationAppointments == null ? List.of() : List.copyOf(destinationAppointments);
|
||||
if (destinationAppointments.size() > 500 || destinationAppointments.stream().map(DestinationAppointment::destinationKey).distinct().count() != destinationAppointments.size())
|
||||
throw new IllegalArgumentException("同一目的仓只能填写一个预约号");
|
||||
appointmentNo = limited(appointmentNo, "本趟预约号", 128);
|
||||
}
|
||||
/** 选项按正式文案保存,拒绝未知值而不猜测业务含义。 */
|
||||
private static String option(String value, String label, String... allowed) {
|
||||
@@ -77,18 +74,6 @@ public final class DispatchContracts {
|
||||
}
|
||||
}
|
||||
|
||||
/** 同一排车按目的仓登记约好的编号,不要求客服逐票或逐托分配。 */
|
||||
public record DestinationAppointment(String destinationKey, String appointmentNo) {
|
||||
/** 草稿允许暂缺编号,目的仓身份及文本长度仍须明确。 */
|
||||
public DestinationAppointment {
|
||||
destinationKey = destinationKey == null ? null : destinationKey.trim();
|
||||
appointmentNo = appointmentNo == null || appointmentNo.isBlank() ? null : appointmentNo.trim();
|
||||
if (destinationKey == null || !destinationKey.startsWith("FBA:") || destinationKey.length() <= 4 || destinationKey.length() > 256)
|
||||
throw new IllegalArgumentException("预约目的仓无效");
|
||||
if (appointmentNo != null && appointmentNo.length() > 128) throw new IllegalArgumentException("预约号最多128字符");
|
||||
}
|
||||
}
|
||||
|
||||
public record Resources(VehicleSnapshot vehicle, DriverSnapshot driver, Maintenance maintenance) { }
|
||||
|
||||
public record Destination(String key, String type, String warehouseId, String warehouseCode,
|
||||
|
||||
@@ -32,15 +32,15 @@ public final class DispatchViews {
|
||||
public record ListRow(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion,
|
||||
ReviewState reviewState, Reviews reviews, Resources resources,
|
||||
String warehouseId, String warehouseCode, String warehouseName,
|
||||
Instant createdAt, Instant plannedDepartureAt, Instant departedAt, Counts counts,
|
||||
Instant plannedDepartureAt, Instant departedAt, Counts counts,
|
||||
boolean pickingReconciled, boolean arranged,
|
||||
Map<Action, Gate> actions, List<FbaAppointment> fbaAppointments) { }
|
||||
Map<Action, Gate> actions) { }
|
||||
|
||||
public record VehicleInfo(VehicleSnapshot vehicle, DriverSnapshot driver, Maintenance maintenance) { }
|
||||
|
||||
public record FbaAppointment(String batchKey, String batchNo, String appointmentNo,
|
||||
String destinationKey, String destinationWarehouseCode,
|
||||
String status, Instant confirmedAt, String blockReason, String customerId, List<String> shipmentNumbers) { }
|
||||
String status, Instant confirmedAt, String blockReason) { }
|
||||
|
||||
public record DispatchInfo(String route, Instant plannedDepartureAt, Instant departedAt,
|
||||
Instant estimatedArrivalAt, List<FbaAppointment> fbaAppointments,
|
||||
|
||||
@@ -14,14 +14,6 @@ public final class DispatchWorkModels {
|
||||
public record Items(String dispatchNo,long recordVersion,Status status,Stage stage,List<CargoItem> items) { }
|
||||
public record Stop(int index,String name,String address,String destinationKey) { }
|
||||
public record Route(String dispatchNo,long recordVersion,List<Stop> stops,Gate edit) { }
|
||||
public record AppointmentItem(Long dispatchItemId,Long planPalletId,String customerId,String customerName,String palletNo,
|
||||
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.AllocationSource> sources,List<BatchAllocation> allocations) { }
|
||||
public record AppointmentAllocations(String dispatchNo,long recordVersion,List<AppointmentItem> items,Gate edit) { }
|
||||
/** 只替换明确选中的本车实物分配,未触及客户和实物保持原关联。 */
|
||||
public record AppointmentAllocationCommand(Long recordVersion,String idempotencyKey,List<Long> dispatchItemIds,List<BatchAllocation> batchAllocations) {
|
||||
/** 主版本零合法;保存失败不能用默认版本覆盖后续修改。 */
|
||||
public AppointmentAllocationCommand {recordVersion=DispatchCommands.requireVersion(recordVersion);}
|
||||
}
|
||||
/** 仅重排现有真实卸货点;起运仓由服务端保留,外接目的地不得丢失。 */
|
||||
public record RouteCommand(Long recordVersion,String idempotencyKey,List<String> destinationKeys,String reason) {
|
||||
/** 显式零版本合法,缺版本不能覆盖并发变更。 */
|
||||
|
||||
-6
@@ -42,10 +42,6 @@ public class DispatchAggregateRepository {
|
||||
public List<DispatchAggregateMapper.Summary> summaries(DispatchAggregateMapper.Search query) { return mapper.summaries(query); }
|
||||
/** 按整车读取全部真实客户明细。 */
|
||||
public List<DispatchItem> cargo(long dispatchId) { return items.findWholeDispatch(dispatchId); }
|
||||
/** 只读取本次确认入库的原计划关联排车。 */
|
||||
public List<String> receiptDispatches(List<Long> planIds) {
|
||||
return planIds.isEmpty()?List.of():items.receiptDispatches(planIds).stream().map(DispatchItemMapper.ReceiptDispatch::dispatchNo).toList();
|
||||
}
|
||||
/** 保存已核对真实客户与仓库的来源快照。 */
|
||||
public int insertItem(DispatchItem item) { return items.insert(item); }
|
||||
/** 未执行明细才可移除,资格和根版本由应用层保证。 */
|
||||
@@ -65,8 +61,6 @@ public class DispatchAggregateRepository {
|
||||
public List<DispatchReadMapper.DeliveryRow> deliveries(long id) { return reads.deliveries(id); }
|
||||
/** 已有关联预约批次只读事实。 */
|
||||
public List<DispatchReadMapper.BatchRow> batches(long id) { return reads.batches(id); }
|
||||
/** 本车实际货物的权威货件号,不依赖预约分配,也不把重复托盘当作新货件。 */
|
||||
public List<DispatchReadMapper.CargoShipment> cargoShipmentNumbers(long id) { return reads.cargoShipmentNumbers(id); }
|
||||
/** 是否必须走受保护提供者核验批次分配。 */
|
||||
public long activeAppointments(String customer, Long pallet) { return reads.activeAppointments(customer,pallet); }
|
||||
}
|
||||
|
||||
+2
-2
@@ -28,8 +28,8 @@ public class DispatchPickingRepository {
|
||||
public int pick(DispatchPdaPick pick){return picks.insert(pick);}
|
||||
/** 本明细实际已扫码数量。 */
|
||||
public long itemPicked(String customer,long task,long item){return picks.totalItem(customer,task,item);}
|
||||
/** 自然收货后保存同一来源实托、箱数和货量,允许未知货量清除计划估算。 */
|
||||
public int materialize(DispatchItem item){return items.confirmReceipt(item);}
|
||||
/** 自然收货后保存已经核实的同一计划来源实托身份。 */
|
||||
public int materialize(DispatchItem item){return items.updateById(item);}
|
||||
/** 按真实客户取本车扫码,不借主单客户头。 */
|
||||
public List<DispatchPdaPick> picks(String customer,long dispatch){return picks.wholeDispatch(customer,dispatch);}
|
||||
}
|
||||
|
||||
-4
@@ -66,10 +66,6 @@ public class DispatchSourceRepository {
|
||||
public DispatchSourceMapper.RelabelBasis relabelBasis(String no) { return source.relabelBasis(no); }
|
||||
/** 取得已有普通计划候选,保留原来源资格SQL。 */
|
||||
public List<DispatchPalletCandidate> selectedPlan(String customer, Long id) { return plans.readSelected(customer, List.of(id)); }
|
||||
/** 确认入库直接读取逐票实收,与可用、暂扣等出货资格分离。 */
|
||||
public List<DispatchPalletCandidate> confirmedPlan(String customer,long id) { return source.confirmedPlan(customer,id); }
|
||||
/** 空预排来源必须具备已完成的零实收事实,不能把来源缺失当作零。 */
|
||||
public boolean confirmedEmptyPlan(String customer,long id,long plan,String warehouse) { return source.confirmedEmptyPlan(customer,id,plan,warehouse)==1; }
|
||||
/** 取得已有真实退回候选。 */
|
||||
public List<DispatchPalletCandidate> selectedReturn(String customer, Long id) { return returns.readSelected(customer, List.of(id)); }
|
||||
/** 同托真实来源订单组成。 */
|
||||
|
||||
+23
-172
@@ -33,7 +33,6 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
@Service
|
||||
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
|
||||
private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class);
|
||||
@jakarta.annotation.Resource private DispatchMasterPhotoService masterPhotos;
|
||||
@jakarta.annotation.Resource private DispatchNumberGenerator numbers;
|
||||
@jakarta.annotation.Resource private DispatchAppointmentAllocations appointments;
|
||||
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
|
||||
@@ -175,7 +174,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var destination=new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(row.destinationType(),row.destinationWarehouseCode(),
|
||||
row.fbaWarehouseCode(),row.deliveryAddress(),row.postalCode()),row.destinationType(),row.destinationWarehouseId(),row.destinationWarehouseCode(),
|
||||
row.destinationWarehouseName(),row.deliveryAddress(),row.postalCode(),row.contactName(),row.contactPhone());
|
||||
var fact=type!=SourceType.INBOUND_PLAN || row.planPalletId()==null?null:batchFacts.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(row.customerId(),row.planPalletId()));
|
||||
var fact=row.planPalletId()==null?null:batchFacts.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(row.customerId(),row.planPalletId()));
|
||||
var references=new ArrayList<BatchReference>();var reasons=new LinkedHashSet<String>();
|
||||
reasons.addAll(group.blockReasons());
|
||||
if(fact!=null) {
|
||||
@@ -269,14 +268,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
}
|
||||
return evidence.download(row.getId(),kind,fileId);
|
||||
}
|
||||
/** 主档驾驶证按明确来源标记读取,本趟补传照片保持排车文件归属。 */
|
||||
/** 未命中冻结MASTER引用的文件仅可按原排车归属读取,保留编辑中尚未保存的本车外配上传。 */
|
||||
private Long masterEvidenceDriverId(DispatchAggregate row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,long fileId) {
|
||||
if(kind!=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER)return null;
|
||||
var resources=read(row.getResourcesJson(),Resources.class);var driver=resources==null?null:resources.driver();
|
||||
if(driver==null || driver.mode()!=ResourceMode.MASTER || driver.evidence()==null || driver.evidence().fileIds()==null
|
||||
|| !driver.evidence().fileIds().contains(fileId))return null;
|
||||
// 只有车队解析并冻结的主档引用才走司机归属,不能把补传图片当作主档文件。
|
||||
if(!Objects.equals(driver.evidence().masterLicenseFileId(),fileId))return null;
|
||||
// 取货允许补传本车驾驶证,仅明确冻结的主档引用才走司机归属。
|
||||
if("PICKUP".equals(row.getTaskType()) && !Objects.equals(driver.evidence().masterLicenseFileId(),fileId))return null;
|
||||
if(driver.driverId()==null || driver.driverId()<=0)throw bad("排车冻结司机档案身份无效,不能读取证照");
|
||||
return driver.driverId();
|
||||
}
|
||||
@@ -311,7 +310,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0)));
|
||||
var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds();
|
||||
if(!photos.isEmpty())verifyEvidence(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
|
||||
Resources resources=masterPhotos.populate(id,actor.operatorId(),resources(shell,photos,null)); row.setResourcesJson(write(resources));
|
||||
Resources resources=resources(shell,photos,null); row.setResourcesJson(write(resources));
|
||||
row.setVehiclePlate(resources.vehicle()==null ? null : resources.vehicle().plate());
|
||||
row.setPlannedDepartureAt(utc(request.plannedDepartureAt())); row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));
|
||||
row.setItemCount(0); row.setPalletCount(0); row.setPackageCount(0); row.setPickedPackageCount(0); row.setShippedPackageCount(0);
|
||||
@@ -381,53 +380,6 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
return saveCommand(row,actor,identity,request.reason());
|
||||
}
|
||||
|
||||
/** 已有排车按当前真实客户托盘读取逐票正式预约,不将本车占用当成其他车占用。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public DispatchWorkModels.AppointmentAllocations appointmentAllocations(Actor actor,String number) {
|
||||
requirePlatform(actor);var row=required(actor,number,false);
|
||||
var items=appointmentItems(row);var options=appointments.options(row.getId(),items);var current=repository.batches(row.getId());
|
||||
var result=items.stream().map(i->new DispatchWorkModels.AppointmentItem(i.getId(),i.getPlanPalletId(),i.getCustomerId(),i.getCustomerName(),i.getPalletNo(),
|
||||
options.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(i.getCustomerId(),i.getPlanPalletId())),
|
||||
current.stream().filter(a->Objects.equals(a.dispatchItemId(),i.getId())).map(a->new BatchAllocation(a.planPalletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packageCount(),a.recordVersion())).toList())).toList();
|
||||
boolean permitted=hasPermission(actor,Action.EDIT);boolean editable=appointmentEditable(row) && !items.isEmpty();
|
||||
return new DispatchWorkModels.AppointmentAllocations(number,row.getRecordVersion(),result,new Gate(permitted && editable,
|
||||
!permitted?"FORBIDDEN":editable?null:"NOT_ELIGIBLE",!permitted?"当前无此操作权限":editable?null:"仅装车前、未扫码且无有效下游单据的FBA货物可关联预约"));
|
||||
}
|
||||
|
||||
/** 原车补选或改选正式预约,分配、主版本、审核失效、历史和命令结果同事务保存。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public CommandResult saveAppointmentAllocations(Actor actor,String number,DispatchWorkModels.AppointmentAllocationCommand request) {
|
||||
var row=lockPlatform(actor,number);
|
||||
if(!hasPermission(actor,Action.EDIT))throw new BizException(ResultCode.FORBIDDEN,"当前无排车维护权限");
|
||||
var identity=identity(actor,number,Action.EDIT,request.recordVersion(),request.idempotencyKey(),request);
|
||||
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
|
||||
requireEditing(row,request.recordVersion());
|
||||
if(!appointmentEditable(row))throw conflict("本车已有实际扫码或有效下游单据,不能调整预约分配");
|
||||
var ids=request.dispatchItemIds();
|
||||
if(ids==null || ids.isEmpty() || ids.size()>1000 || ids.stream().anyMatch(Objects::isNull) || new HashSet<>(ids).size()!=ids.size())throw bad("请选择完整且不重复的本车货物明细");
|
||||
if(request.batchAllocations()==null || request.batchAllocations().size()>1000)throw bad("请明确本次正式预约分配");
|
||||
var touched=appointmentItems(row).stream().filter(i->ids.contains(i.getId())).toList();
|
||||
if(touched.size()!=ids.size())throw conflict("预约分配只能调整本车已有的真实FBA货物");
|
||||
// 先父车/资源/主单,再全部旧新预约,最后订单/实托/计划来源;不反向追加批次锁。
|
||||
appointments.lock(repository.batches(row.getId()),request.batchAllocations());
|
||||
sourceRules.lockAppointmentSources(row.getOverseasWarehouseId(),touched);
|
||||
appointments.bind(row.getId(),touched,request.batchAllocations());
|
||||
String reason="正式预约分配已调整,请客服重新提交两级审核";invalidate(row,reason);
|
||||
return saveCommand(row,actor,identity,reason,new DispatchResults.Saved(number,touched.stream().map(DispatchItem::getId).toList()));
|
||||
}
|
||||
/** 只处理普通计划来源的FBA货物,其他来源与客户原分配保持不动。 */
|
||||
private List<DispatchItem> appointmentItems(DispatchAggregate row) {
|
||||
return repository.cargo(row.getId()).stream().filter(i->i.getPlanPalletId()!=null && "FBA".equals(i.getDestinationType())
|
||||
&& !"RELABEL_ORDER".equals(i.getSourceBusinessType())).toList();
|
||||
}
|
||||
/** 显式查真实扫码流水,不以主单零计数覆盖实际执行事实。 */
|
||||
private boolean appointmentEditable(DispatchAggregate row) {
|
||||
return (row.getTaskType()==null || "DELIVERY".equals(row.getTaskType())) && "ACTIVE".equals(row.getStatus()) && "BEFORE_LOADING".equals(row.getStage())
|
||||
&& n(row.getPickedPackageCount())==0 && n(row.getShippedPackageCount())==0 && repository.realPicked(row.getId())==0
|
||||
&& repository.outbounds(row.getId()).stream().allMatch(o->"CANCELLED".equals(o.status()))
|
||||
&& repository.deliveries(row.getId()).stream().allMatch(d->"CANCELLED".equals(d.status()));
|
||||
}
|
||||
|
||||
/** 旧新资源身份一次锁齐,资料和本趟照片随正式资源快照保存,原审核失效规则保留。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public CommandResult update(Actor actor,String number,DispatchCommands.Update request) {
|
||||
@@ -442,17 +394,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
if(!photos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
|
||||
var vehiclePhotos=request.vehicle()==null?null:request.vehicle().evidenceFileIds();
|
||||
if(vehiclePhotos!=null && !vehiclePhotos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,vehiclePhotos);
|
||||
var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),updatedFleetDriver(request.driver(),read(row.getResourcesJson(),Resources.class)));
|
||||
DispatchDestinationAppointments.validate(request.maintenance(),reviewAppointments(row));
|
||||
var savedResources=masterPhotos.populate(row.getId(),actor.operatorId(),resources(actual,photos,request.maintenance()));
|
||||
// 主档引用只能由车队解析产生;补传照片必须核验本车归属后才允许保存。
|
||||
var savedDriver=savedResources.driver();
|
||||
if(savedDriver!=null && savedDriver.evidence()!=null)for(var id:savedDriver.evidence().fileIds()) {
|
||||
if(!Objects.equals(id,savedDriver.evidence().masterLicenseFileId()))evidence.metadata(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,id);
|
||||
}
|
||||
row.setResourcesJson(write(savedResources));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
|
||||
var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver()));
|
||||
row.setResourcesJson(write(resources(actual,photos,request.maintenance())));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
|
||||
row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark());
|
||||
// 发布选择仅在建单时确定,维护车辆不得取消或重新发布需求。
|
||||
row.setPublishLooseboardDemand(request.publishLooseboardDemand());
|
||||
if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改");
|
||||
fleet.saveDispatchResources(number,actor.operatorId(),actual,request.plannedDepartureAt(),request.remark());
|
||||
return saveCommand(row,actor,identity,request.remark());
|
||||
@@ -620,8 +565,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
if(items.isEmpty() || items.stream().anyMatch(i->i.getWarehousePalletId()==null))throw conflict("逐项真实拣货尚未核平");
|
||||
lockExecutionSources(row,items);
|
||||
var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平");
|
||||
String appointmentBlock=reviewAppointmentBlock(row);
|
||||
if(appointmentBlock==null)appointmentBlock=appointments.departureBlockReason(number);
|
||||
String appointmentBlock=appointments.departureBlockReason(number);
|
||||
if(appointmentBlock!=null)throw conflict(appointmentBlock);
|
||||
if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对");
|
||||
if(preview.pallets().stream().anyMatch(p->p.deliveryIndex()==null))throw conflict("托盘卸货站尚未匹配,请调度核对路线");
|
||||
@@ -665,7 +609,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var origin=pick.dispatchItemSourceId()==null?null:origins.get(pick.dispatchItemSourceId());
|
||||
var sourceOrigin=origin==null?null:new SourceOrigin(origin.getId(),origin.getRelabelApplicationId(),origin.getRelabelItemId(),origin.getSourceInventoryBalanceId(),origin.getSourceInboundOrderId(),origin.getSourceInboundOrderNo(),origin.getSourceInboundCode(),origin.getProductQuantity()!=null || pick.productQuantity()>0?pick.productQuantity():null);
|
||||
cargo.add(new CargoLine(item.getId(),pick.customerId(),item.getCustomerName(),pick.orderId(),pick.orderNo(),source,item.getSourceStage(),pick.sourceRegion(),pick.sourceInboundId(),pick.inboundCode(),
|
||||
item.getPlanPalletId(),item.getWarehousePalletId(),item.getPalletNo(),pick.pickId(),pick.attributionId(),pick.positionId(),pick.receiptId(),pick.returnReceiptId(),pick.returnCartonId(),origin==null?pick.cartonMark():relabelCartonMarks(origin),
|
||||
item.getPlanPalletId(),item.getWarehousePalletId(),item.getPalletNo(),pick.pickId(),pick.attributionId(),pick.positionId(),pick.receiptId(),pick.returnReceiptId(),pick.returnCartonId(),pick.cartonMark(),
|
||||
"RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getSourceBusinessNo():null,destination(item),pick.packages(),pick.productQuantity(),null,null,allocations,sourceOrigin));
|
||||
}
|
||||
var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages()));
|
||||
@@ -676,22 +620,6 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
read(row.getResourcesJson(),Resources.class),counts(row),reviews(row),complete,List.copyOf(cargo));
|
||||
}
|
||||
|
||||
/** 普通换标出库沿本次冻结贡献的新箱标,不从退货箱表或原箱唛生成快照。 */
|
||||
private String relabelCartonMarks(com.baiyue.lastleg.dispatch.entity.DispatchItemSource origin) {
|
||||
try {
|
||||
var facts=json.readTree(origin.getUnitFactsJson());var marks=new java.util.TreeSet<String>();
|
||||
if(!facts.isArray())throw conflict("换标冻结箱标事实缺失");
|
||||
for(var fact:facts)if("RELABELLED".equals(fact.path("result").asText())) {
|
||||
var mark=fact.path("cartonMark").asText("").trim();
|
||||
if(mark.isEmpty() || !marks.add(mark))throw conflict("换标冻结新箱标缺失或重复");
|
||||
}
|
||||
if(marks.isEmpty())throw conflict("换标冻结新箱标缺失");
|
||||
return String.join(",",marks);
|
||||
} catch(com.fasterxml.jackson.core.JsonProcessingException error) {
|
||||
throw new IllegalStateException("换标冻结箱标事实无法读取",error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 共同根锁顺序固定,当前阶段资格留给原命令恢复之后。 */
|
||||
private DispatchAggregate lockPlatform(Actor actor,String number) { requirePlatform(actor);lockReviewRoot(actor,number);return required(actor,number,true); }
|
||||
/** 运输取货锁原车次,仓库派送锁其整车资源;统一先父后主单。 */
|
||||
@@ -717,49 +645,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var facts=new TreeMap<String,Object>();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class));
|
||||
facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt());
|
||||
if("PICKUP".equals(row.getTaskType()))facts.put("route",transportTasks.transport(row.getDispatchNo(),false).getStopsJson());
|
||||
else {
|
||||
var batches=json.valueToTree(repository.batches(row.getId()));
|
||||
// 客户仓的DISPATCH引用属于本车自身,扫码递增公共版本不代表预约或货物依据变化。
|
||||
for(var batch:batches)if(("DISPATCH:"+row.getId()).equals(batch.path("batchKey").asText()))
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode)batch).remove("recordVersion");
|
||||
facts.put("batches",batches);facts.put("route",fleet.dispatchStops(row.getDispatchNo()));facts.put("receiving",reviewAppointments(row));
|
||||
}
|
||||
else {facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));facts.put("receiving",appointments.reviewEvidence(row.getDispatchNo()));}
|
||||
return hashes.source(facts);
|
||||
}
|
||||
/** 交接前记住原审核依据是否仍有效,不借实收替资源或预约变化重新背书。 */
|
||||
boolean currentReceiptBasis(DispatchAggregate row) {
|
||||
return row.getReviewBasisHash()!=null && Objects.equals(row.getReviewBasisHash(),basis(row));
|
||||
}
|
||||
|
||||
/** 自然实收只延续仍有效的原审核依据,失效审核继续保持失效。 */
|
||||
void saveReceivedCargo(DispatchAggregate row,String operator,LocalDateTime at,boolean preserveReview) {
|
||||
long version=row.getRecordVersion();
|
||||
// 空预排明细已移除后按剩余真实货物重算,避免列表、路线和车次继续保留幽灵托盘。
|
||||
var all=repository.cargo(row.getId());
|
||||
boolean removedItems=!Objects.equals(row.getItemCount(),all.size());
|
||||
int previousPackages=row.getPackageCount();
|
||||
row.setPackageCount(Math.toIntExact(all.stream().mapToLong(DispatchItem::getPackageCount).sum()));
|
||||
row.setItemCount(all.size());
|
||||
row.setPalletCount(Math.toIntExact(all.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count()));
|
||||
if(removedItems)row.setRoute(all.stream().map(this::destinationLabel).filter(Objects::nonNull).distinct().sorted().collect(Collectors.joining(",")));
|
||||
var digest=all.stream().sorted(Comparator.comparing(DispatchItem::getSourceKey)).map(i->List.of(i.getSourceKey(),i.getSourceVersion(),i.getCustomerId(),i.getPackageCount())).toList();
|
||||
row.setCargoHash(hashes.source(digest));row.setCargoRevision(Math.addExact(row.getCargoRevision(),1));
|
||||
if(removedItems || previousPackages!=row.getPackageCount()) {
|
||||
var stops=all.stream().map(i->new FleetExecutionModels.DispatchStop(destinationLabel(i),i.getDeliveryAddress(),destination(i).key()))
|
||||
.collect(Collectors.toMap(FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
|
||||
fleet.updateDispatchCargo(row.getDispatchNo(),row.getPackageCount(),stops);
|
||||
}
|
||||
if(all.isEmpty()) {
|
||||
invalidate(row,"实收核对后本车无货物,请重新安排货物并提交审核");
|
||||
row.setStage(Stage.BEFORE_LOADING.name());preserveReview=false;
|
||||
}
|
||||
if(preserveReview)row.setReviewBasisHash(basis(row));
|
||||
row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at);
|
||||
if(repository.saveState(row,version)!=1)throw conflict("收货交接期间排车版本已变化");
|
||||
if(repository.history(IdWorker.getId(),row,"RECEIVING_COMPLETE",operator,"按确认入库的逐托实收交接原预排",write(row),at)!=1)
|
||||
throw conflict("排车实收交接历史未保存");
|
||||
}
|
||||
|
||||
/** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */
|
||||
private void invalidate(DispatchAggregate row,String reason) {
|
||||
row.setReviewState(ReviewState.DRAFT.name());row.setReviewBasisHash(null);
|
||||
@@ -790,38 +678,20 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var capacity=v.capacity();if(capacity==null || capacity.palletSpaces()==null || capacity.palletSpaces()<=0 || capacity.maxLoadKg()==null || capacity.maxLoadKg().signum()<=0 || capacity.maxVolumeCbm()==null || capacity.maxVolumeCbm().signum()<=0)throw bad("请补齐车辆真实托位、载重及容积");
|
||||
var e=d.evidence();if(e==null || e.expectedTaskEndAt()==null || row.getPlannedDepartureAt()==null || !e.expectedTaskEndAt().isAfter(instant(row.getPlannedDepartureAt())) || !e.expectedTaskEndAt().isAfter(Instant.now()))throw bad("预计任务结束须晚于发车及当前时间");
|
||||
if(trim(e.licenseNo())==null || e.licenseExpiresOn()==null || e.licenseExpiresOn().isBefore(e.expectedTaskEndAt().atZone(ZoneOffset.UTC).toLocalDate()))throw bad("司机证照须覆盖预计任务结束日期");
|
||||
verifyReviewPhotos(row,r);
|
||||
if(v.mode()==ResourceMode.EXTERNAL || v.evidenceFileIds()!=null && !v.evidenceFileIds().isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,v.evidenceFileIds());
|
||||
if(d.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,e.fileIds());
|
||||
if(!e.photoFileIds().isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,e.photoFileIds());
|
||||
var actual=fleet.dispatchResources(row.getDispatchNo(),new FleetExecutionModels.DispatchVehicle(v.mode().name(),v.vehicleId(),v.plate(),v.carrierName(),capacity.palletSpaces(),capacity.maxLoadKg(),capacity.maxVolumeCbm(),v.evidenceFileIds()),
|
||||
new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt()));
|
||||
if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds(),r.maintenance()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
|
||||
}
|
||||
/** 车辆和司机须有有效图片,驾驶证接受与档案一致的PDF或图片。 */
|
||||
private void verifyReviewPhotos(DispatchAggregate row,Resources resources) {
|
||||
var driver=resources.driver();var proof=driver.evidence();
|
||||
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,resources.vehicle().evidenceFileIds(),"车辆图片");
|
||||
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,proof.photoFileIds(),"司机照片");
|
||||
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,proof.fileIds(),"驾驶证照片");
|
||||
}
|
||||
/** 文件存在、归属及有效性由原提供者复验,PDF仅可作为驾驶证。 */
|
||||
private void requireReviewPhoto(DispatchAggregate row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List<Long> ids,String label) {
|
||||
if(ids==null || ids.isEmpty() || ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("请上传至少一张有效"+label);
|
||||
boolean image=false;
|
||||
for(var id:ids) {
|
||||
var master=masterEvidenceDriverId(row,kind,id);
|
||||
var file=master==null?evidence.metadata(row.getId(),kind,id):driverEvidenceMetadata(fleet.dispatchDriverLicense(master,id).metadata(),id);
|
||||
if(file!=null && ("image/jpeg".equals(file.contentType()) || "image/png".equals(file.contentType())
|
||||
|| kind==com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER && "application/pdf".equals(file.contentType())))image=true;
|
||||
}
|
||||
if(!image)throw bad("请上传至少一张有效"+label);
|
||||
}
|
||||
/** 从客服提交到真实扫码共用预约及时间复验,保留预排但不放行未确认货物。 */
|
||||
void requireReviewAppointment(DispatchAggregate row) {
|
||||
String reason=reviewAppointmentBlock(row);
|
||||
if(reason!=null)throw conflict(reason);
|
||||
}
|
||||
private String reviewAppointmentBlock(DispatchAggregate row) {
|
||||
String reason=DispatchDestinationAppointments.blockReason(reviewAppointments(row),instant(row.getPlannedDepartureAt()),instant(row.getEstimatedArrivalAt()),appointments.reviewEvidence(row.getDispatchNo()));
|
||||
if(reason==null)reason=appointments.reviewBlockReason(row.getDispatchNo(),instant(row.getPlannedDepartureAt()),instant(row.getEstimatedArrivalAt()));
|
||||
String reason=appointments.reviewBlockReason(row.getDispatchNo(),instant(row.getPlannedDepartureAt()),instant(row.getEstimatedArrivalAt()));
|
||||
if(reason==null && row.getReviewBasisHash()!=null && !canSubmitReview(row.getReviewState()) && !Objects.equals(row.getReviewBasisHash(),basis(row)))
|
||||
return "预约或运输安排已变化,原审核依据失效,请重新核对审核";
|
||||
return reason;
|
||||
@@ -883,26 +753,20 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
statistics(repository.summaries(filter)),Instant.now());
|
||||
}
|
||||
|
||||
/** Admin与PDA共用本车目的仓预约,每个目的仓仅返回一行。 */
|
||||
/** Admin与PDA审核读取相同的当前有效预约,未确认成员不能被已确认成员覆盖。 */
|
||||
List<DispatchViews.FbaAppointment> reviewAppointments(DispatchAggregate row) {
|
||||
var resources=read(row.getResourcesJson(),Resources.class);
|
||||
return DispatchDestinationAppointments.resolve(repository.cargo(row.getId()),resources==null?null:resources.maintenance(),bookedAppointments(row),repository.cargoShipmentNumbers(row.getId()));
|
||||
}
|
||||
/** 原预约仅用于带出已有确认号,客服按目的仓维护后不再逐票录入。 */
|
||||
private List<DispatchViews.FbaAppointment> bookedAppointments(DispatchAggregate row) {
|
||||
String number=row.getDispatchNo();var batches=repository.batches(row.getId()).stream().filter(b->b.batchKey().startsWith("FBA_APPOINTMENT:")).toList();
|
||||
if(batches.isEmpty())return List.of();
|
||||
String number=row.getDispatchNo();var batches=repository.batches(row.getId());
|
||||
var confirmedAppointments=this.appointments.reviewEvidence(number);
|
||||
return batches.stream().collect(Collectors.toMap(
|
||||
return batches.stream().filter(b -> b.batchKey().startsWith("FBA_APPOINTMENT:")).collect(Collectors.toMap(
|
||||
b -> List.of(b.batchKey(),b.customerId(),b.destinationKey()),b->b,(a,b)->a,LinkedHashMap::new)).values().stream().map(b -> {
|
||||
var confirmed=confirmedAppointments.stream().filter(w->w.batchKey().equals(b.batchKey()) && w.customerId().equals(b.customerId()) && w.destinationKey().equals(b.destinationKey())).findFirst().orElse(null);
|
||||
return new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),confirmed==null?null:confirmed.confirmationNo(),b.destinationKey(),b.warehouseCode(),
|
||||
confirmed==null?"PENDING":"SUCCESS",confirmed==null?null:confirmed.startAt(),confirmed==null?"尚无当前有效收货确认":null,b.customerId(),List.of());
|
||||
confirmed==null?"PENDING":"SUCCESS",confirmed==null?null:confirmed.startAt(),confirmed==null?"尚无当前有效收货确认":null);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/** 一次读取真实全部客户明细和单据,客户端不暴露整车入口。 */
|
||||
/** 四页签读取同一资源快照,维护资料及本趟照片保持直接业务归属。 */
|
||||
/** 四页签读取同一资源快照,维护资料和本趟车辆照片不回退为主档或备注。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public DispatchViews.Detail detail(Actor actor, String number) {
|
||||
var row=required(actor,number,false); var cargo=repository.cargo(row.getId());
|
||||
@@ -947,7 +811,6 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
for(var source:resolved) {
|
||||
if(keys.contains(source.sourceKey())) throw conflict("所选来源已在本车,不能重复加入");
|
||||
var c=source.candidate();
|
||||
if(source.selection().sourceType()==SourceType.INBOUND_PLAN && "FBA".equals(c.destinationType()) && c.planPalletId()==null)throw conflict("本票FBA货物缺少可核对的正式预约来源,请先确认送仓时间");
|
||||
sources.expire(c.customerId(),source.sourceKey(),at);
|
||||
if(!sources.claims(c.customerId(),source.sourceKey(),at).isEmpty())throw conflict("所选逻辑来源已被有效运输安排占用");
|
||||
try {if(sources.claim(IdWorker.getId(),c.customerId(),source.sourceKey(),c.planPalletId(),c.warehousePalletId(),c.returnReceiptId(),c.warehousePalletId()==null?null:physical.get(c.warehousePalletId()),row.getId(),actor.operatorId(),at)!=1)throw conflict("来源占用未保存");}
|
||||
@@ -1122,10 +985,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
new DispatchViews.ReviewCounts(dispatch[0],dispatch[1],dispatch[2],dispatch[3]),new DispatchViews.ReviewCounts(loading[0],loading[1],loading[2],loading[3])));
|
||||
}
|
||||
|
||||
/** 列表带出与详情相同的正式预约投影,不读取附件或派送。 */
|
||||
/** 列表不逐行读取明细、附件或派送。 */
|
||||
private DispatchViews.ListRow listRow(Actor actor,DispatchAggregate row) {
|
||||
return new DispatchViews.ListRow(row.getDispatchNo(),row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
|
||||
reviews(row),read(row.getResourcesJson(),Resources.class),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),instant(row.getCreatedAt()),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),counts(row),reconciled(row),row.getArrangementKey()!=null,actions(actor,row),reviewAppointments(row));
|
||||
reviews(row),read(row.getResourcesJson(),Resources.class),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),counts(row),reconciled(row),row.getArrangementKey()!=null,actions(actor,row));
|
||||
}
|
||||
|
||||
/** 混托多订单无法真实分摊重量体积时保持未知,不能给每票复制整托总量。 */
|
||||
@@ -1146,8 +1009,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var v=shell.vehicle(); var d=shell.driver();
|
||||
return new Resources(v==null?null:new VehicleSnapshot(ResourceMode.valueOf(v.mode()),v.vehicleId(),v.plate(),v.carrierName(),shell.vehicleStatus(),
|
||||
new Capacity(v.palletSpaces(),v.maxLoadKg(),v.maxVolumeCbm()),v.evidenceFileIds()),d==null?null:new DriverSnapshot(ResourceMode.valueOf(d.mode()),d.driverId(),d.name(),d.phone(),
|
||||
new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt(),photos,
|
||||
"MASTER".equals(d.mode()) && !d.evidenceFileIds().isEmpty()?d.evidenceFileIds().get(0):null)),maintenance);
|
||||
new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt(),photos)),maintenance);
|
||||
}
|
||||
|
||||
/** 主档选择不接受请求覆盖其车牌和容量。 */
|
||||
@@ -1157,17 +1019,6 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
v.capacity()==null?null:v.capacity().maxLoadKg(),v.capacity()==null?null:v.capacity().maxVolumeCbm(),v.evidenceFileIds()==null?List.of():List.copyOf(v.evidenceFileIds()));
|
||||
}
|
||||
|
||||
/** 旧主档证照由服务端保存的来源标记识别;换证后只向车队传本趟补传文件,不能信任请求自报来源。 */
|
||||
private static FleetExecutionModels.DispatchDriver updatedFleetDriver(DispatchCommands.Driver requested,Resources previous) {
|
||||
var driver=fleetDriver(requested);
|
||||
var old=previous==null?null:previous.driver();
|
||||
if(driver==null || old==null || old.mode()!=ResourceMode.MASTER || requested.mode()!=ResourceMode.MASTER
|
||||
|| !Objects.equals(old.driverId(),requested.driverId()) || old.evidence()==null || old.evidence().masterLicenseFileId()==null)return driver;
|
||||
var oldMaster=old.evidence().masterLicenseFileId();
|
||||
return new FleetExecutionModels.DispatchDriver(driver.mode(),driver.driverId(),driver.name(),driver.phone(),driver.licenseNo(),driver.licenseExpiresOn(),
|
||||
driver.evidenceFileIds().stream().filter(id->!Objects.equals(id,oldMaster)).toList(),driver.expectedTaskEndAt());
|
||||
}
|
||||
|
||||
/** 司机证据是本趟输入,主档事实仍由Fleet提供者核验。 */
|
||||
private static FleetExecutionModels.DispatchDriver fleetDriver(DispatchCommands.Driver d) {
|
||||
if(d==null) return null; if(d.mode()==null) throw bad("请选择司机来源方式"); var e=d.evidence();
|
||||
|
||||
-8
@@ -22,14 +22,6 @@ public class DispatchAppointmentAllocations {
|
||||
/** 预排与执行门禁分开;审核之前不要求已有拣货记录。 */
|
||||
public String reviewBlockReason(String dispatchNo,java.time.Instant departure,java.time.Instant arrival){return provider.reviewBlockReason(dispatchNo,departure,arrival);}
|
||||
public List<AppointmentWorkDispatchPort.ReceivingWindow> reviewEvidence(String dispatchNo){return provider.reviewEvidence(dispatchNo);}
|
||||
/** 本车货物已经由主单范围核实,选项不重新选货或制造托盘占用。 */
|
||||
public Map<AppointmentWorkDispatchPort.CandidatePhysical,List<AppointmentWorkDispatchPort.AllocationSource>> options(long dispatch,List<DispatchItem> items) {
|
||||
var identities=items.stream().map(i->new AppointmentWorkDispatchPort.CandidatePhysical(i.getCustomerId(),i.getPlanPalletId())).distinct().toList();
|
||||
if(identities.isEmpty())return Map.of();
|
||||
var result=provider.allocationOptions(dispatch,identities);
|
||||
if(result==null || !result.keySet().equals(new HashSet<>(identities)))throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"本车预约选项范围不完整,请重新读取");
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 一整页只做一次Provider批读;没有计划来源的独立退货不伪造计划ID。 */
|
||||
public Map<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> candidates(List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> rows) {
|
||||
|
||||
-6
@@ -58,12 +58,6 @@ public class DispatchBolReadService {
|
||||
remaining.forEach((key,count)->{if(count>0)result.add(copy(cargo.get(key),count,null));});
|
||||
var printedTotals=new HashMap<Long,Integer>();result.forEach(line->printedTotals.merge(line.dispatchItemId(),line.packageCount(),Math::addExact));
|
||||
if(!printedTotals.equals(totals))throw invalid();
|
||||
// 仅已填写编号覆盖同仓来源;空草稿保留后来取得的正式预约号,不改变各客户货量。
|
||||
if(resources.maintenance()!=null) {
|
||||
var numbers=new HashMap<String,String>();resources.maintenance().destinationAppointments().stream()
|
||||
.filter(a->a.appointmentNo()!=null).forEach(a->numbers.put(a.destinationKey(),a.appointmentNo()));
|
||||
result.replaceAll(line->numbers.containsKey(line.destinationKey())?copy(line,line.packageCount(),numbers.get(line.destinationKey())):line);
|
||||
}
|
||||
return new DispatchBolData(row.getDispatchNo(),row.getOverseasWarehouseId(),row.getOverseasWarehouseName(),resources.vehicle()==null?null:resources.vehicle().carrierName(),resources.vehicle()==null?null:resources.vehicle().plate(),
|
||||
row.getEstimatedArrivalAt()==null?null:LocalDateTime.ofInstant(row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC),ZoneId.systemDefault()),List.copyOf(result));
|
||||
}
|
||||
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Maintenance;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchViews.FbaAppointment;
|
||||
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
/** 本车送仓预约按目的仓汇总;货物的客户归属及真实数量仍由原明细维护。 */
|
||||
final class DispatchDestinationAppointments {
|
||||
/** 纯规则容器不持有数据访问或单据状态。 */
|
||||
private DispatchDestinationAppointments() { }
|
||||
|
||||
/** 先预约的唯一确认号自动带入;同仓多个不同号时交由客服填写本车实际使用的一个号。 */
|
||||
static List<FbaAppointment> resolve(List<DispatchItem> cargo, Maintenance maintenance, List<FbaAppointment> booked, List<com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.CargoShipment> cargoShipments) {
|
||||
var destinations = new TreeMap<String,String>();
|
||||
for (var item : cargo) if ("FBA".equals(item.getDestinationType())) {
|
||||
var key = com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(item.getDestinationType(),item.getDestinationWarehouseCode(),item.getFbaWarehouseCode(),item.getDeliveryAddress(),item.getPostalCode());
|
||||
if (key == null) throw new BizException(ResultCode.CONFLICT,"本车FBA货物缺少目的仓码");
|
||||
destinations.put(key, key.substring(4));
|
||||
}
|
||||
var entered = new HashMap<String,String>();
|
||||
if (maintenance != null) for (var entry : maintenance.destinationAppointments()) entered.put(entry.destinationKey(),entry.appointmentNo());
|
||||
var result = new ArrayList<FbaAppointment>();
|
||||
destinations.forEach((key,code) -> {
|
||||
var confirmed = booked.stream().filter(a -> key.equals(a.destinationKey()) && "SUCCESS".equals(a.status()) && a.appointmentNo() != null && !a.appointmentNo().isBlank()).toList();
|
||||
var numbers = confirmed.stream().map(FbaAppointment::appointmentNo).distinct().toList();
|
||||
// 草稿暂未填写不能遮挡后来取得的唯一正式确认号,已填写值仍保留。
|
||||
var enteredNumber = entered.get(key);
|
||||
var number = enteredNumber != null ? enteredNumber : numbers.size() == 1 ? numbers.get(0) : null;
|
||||
var matching = confirmed.stream().filter(a -> Objects.equals(number,a.appointmentNo())).toList();
|
||||
var at = matching.stream().map(FbaAppointment::confirmedAt).filter(Objects::nonNull).min(Comparator.naturalOrder()).orElse(null);
|
||||
var shipments = cargoShipments.stream().filter(a -> key.equals(a.destinationKey())).map(com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.CargoShipment::shipmentNo).filter(Objects::nonNull).map(String::trim).filter(s -> !s.isEmpty()).distinct().sorted().toList();
|
||||
result.add(new FbaAppointment(null,null,number,key,code,number == null ? "PENDING" : at == null ? "REGISTERED" : "SUCCESS",at,
|
||||
number == null ? "请填写目的仓 " + code + " 的预约号" : null,null,shipments));
|
||||
});
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** 保存允许暂缺编号,但不能把别的目的仓写入本车。 */
|
||||
static void validate(Maintenance maintenance, List<FbaAppointment> destinations) {
|
||||
if (maintenance == null) return;
|
||||
var keys = destinations.stream().map(FbaAppointment::destinationKey).toList();
|
||||
if (maintenance.destinationAppointments().stream().anyMatch(a -> !keys.contains(a.destinationKey())))
|
||||
throw new BizException(ResultCode.BAD_REQUEST,"预约只能填写本车已有的目的仓");
|
||||
}
|
||||
|
||||
/** 预约完整性只在提交审核及后续执行检查;明确已知的正式时间继续参与核对。 */
|
||||
static String blockReason(List<FbaAppointment> destinations, Instant departure, Instant arrival, List<ReceivingWindow> windows) {
|
||||
var missing = destinations.stream().filter(a -> a.appointmentNo() == null).map(FbaAppointment::destinationWarehouseCode).toList();
|
||||
if (!missing.isEmpty()) return "请填写目的仓 " + String.join("、",missing) + " 的预约号后提交审核";
|
||||
if (destinations.isEmpty()) return null;
|
||||
if (departure != null && arrival != null && arrival.isBefore(departure)) return "预计送达时间不能早于计划发车时间";
|
||||
var first = windows.stream().filter(w -> destinations.stream().anyMatch(a -> w.destinationKey().equals(a.destinationKey()) && Objects.equals(w.confirmationNo(),a.appointmentNo())))
|
||||
.min(Comparator.comparing(ReceivingWindow::startAt)).orElse(null);
|
||||
if (first != null) {
|
||||
if (departure == null || arrival == null) return "请先明确计划发车时间和预计送达时间,再提交审核";
|
||||
if (!departure.isBefore(first.startAt())) return "计划发车时间须早于本车最早的FBA预约送达时间";
|
||||
if (arrival.isAfter(first.endAt() == null ? first.startAt() : first.endAt())) return "预计送达时间晚于本车首个FBA预约收货时间,请调度重新核对";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+3
-9
@@ -113,9 +113,7 @@ public class DispatchDraftService implements DispatchPlanningPort {
|
||||
var resolved=lockedIndex.get(member.sourceKey());
|
||||
if(!Objects.equals(resolved.selection(),member.selection()) || !Objects.equals(resolved.candidate().customerId(),member.customerId())
|
||||
|| resolved.candidate().packageCount()==null || resolved.candidate().packageCount().longValue()!=member.packages())throw conflict("锁内完整来源身份、版本或箱数已变化");
|
||||
var projected=candidate(resolved,group);
|
||||
if(!projected.blockReasons().isEmpty())throw conflict(String.join(";",projected.blockReasons()));
|
||||
next.add(projected);keys.add(member.sourceKey());
|
||||
next.add(candidate(resolved,group));keys.add(member.sourceKey());
|
||||
}
|
||||
requireCapacity(container.getCapacity(),keys,index(next));
|
||||
var at=now();var physical=physicalClaims.reserve(physicalOwner(row,container.getId()),members.values().stream()
|
||||
@@ -423,18 +421,14 @@ public class DispatchDraftService implements DispatchPlanningPort {
|
||||
.map(c->new DraftContainer(c.getId(),c.getSequenceNo(),c.getCapacity(),keys(c))).toList(),selected(row).stream().map(c->projectCandidate(c,batches)).toList(),row.getExpiresAt().toInstant(ZoneOffset.UTC));}
|
||||
/** 真实客户及计划托盘来自服务器来源,整页一次调用原Provider。 */
|
||||
private Map<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> readCandidateBatches(List<Candidate> candidates) {
|
||||
var identities=candidates.stream().filter(c->c.selection().sourceType()==DispatchTypes.SourceType.INBOUND_PLAN && c.selection().planPalletId()!=null).map(c->new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId())).distinct().toList();
|
||||
var identities=candidates.stream().filter(c->c.selection().planPalletId()!=null).map(c->new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId())).distinct().toList();
|
||||
var result=new LinkedHashMap<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches>();
|
||||
for(int offset=0;offset<identities.size();offset+=1000)result.putAll(appointmentProvider.candidateBatches(identities.subList(offset,Math.min(offset+1000,identities.size()))));
|
||||
return result;
|
||||
}
|
||||
/** 显式映射原批次版本;本草稿有效占用不作为所选来源自身的阻断理由。 */
|
||||
private Candidate projectCandidate(Candidate c,Map<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> batches) {
|
||||
if(c.selection().sourceType()!=DispatchTypes.SourceType.INBOUND_PLAN)return c;
|
||||
if(c.selection().planPalletId()==null) {
|
||||
if(!"FBA".equals(c.destination().type()))return c;
|
||||
return new Candidate(c.selection(),c.sourceKey(),c.sourceVersion(),c.customerId(),c.customerName(),c.warehouseId(),c.warehouseCode(),c.warehouseName(),c.orderNo(),c.inboundCode(),c.planNo(),c.palletNo(),c.containerNo(),c.destination(),c.availablePackages(),c.suggestedWeightKg(),c.suggestedVolumeCbm(),c.measurementSource(),List.of(),List.of("本票FBA货物缺少可核对的正式预约来源,请先确认送仓时间"),c.selectionGroup());
|
||||
}
|
||||
if(c.selection().planPalletId()==null)return c;
|
||||
var result=batches.get(new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId()));
|
||||
var reasons=new LinkedHashSet<String>();
|
||||
if(result==null)reasons.add("预约批次实物读取不完整,请重新核对来源");else reasons.addAll(result.blockReasons());
|
||||
|
||||
-123
@@ -1,123 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.repository.*;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import org.springframework.transaction.support.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
/** 确认入库时完成原预排实收交接,出货只执行已确认的逐托数量。 */
|
||||
@Service
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public class DispatchInboundReceiptService implements InboundDispatchReceiptGateway {
|
||||
private final DispatchAggregateRepository repository;
|
||||
private final DispatchSourceRepository sources;
|
||||
private final DispatchPickingRepository picking;
|
||||
private final DispatchPhysicalClaimService physicalClaims;
|
||||
private final DispatchAggregateService workflow;
|
||||
private final FleetTripExecutionService fleet;
|
||||
private final InboundDispatchReceiptGateway.Appointments appointments;
|
||||
private final Object locksKey=new Object();
|
||||
|
||||
/** 使用原排车、实物占用及预约边界,不新增库存出库动作。 */
|
||||
public DispatchInboundReceiptService(DispatchAggregateRepository repository, DispatchSourceRepository sources,
|
||||
DispatchPickingRepository picking,
|
||||
DispatchPhysicalClaimService physicalClaims, DispatchAggregateService workflow, FleetTripExecutionService fleet,
|
||||
InboundDispatchReceiptGateway.Appointments appointments) {
|
||||
this.repository=repository;this.sources=sources;this.picking=picking;
|
||||
this.physicalClaims=physicalClaims;this.workflow=workflow;this.fleet=fleet;this.appointments=appointments;
|
||||
}
|
||||
|
||||
/** 先车辆父锁、排车主锁和预约锁,再由收货链路锁来源与库存。 */
|
||||
@Override public void lock(List<Long> planIds) {
|
||||
var numbers=repository.receiptDispatches(planIds);
|
||||
@SuppressWarnings("unchecked") var previous=(Set<String>)TransactionSynchronizationManager.getResource(locksKey);
|
||||
if(previous!=null) {
|
||||
if(!previous.containsAll(numbers))throw conflict("收货期间关联排车已变化,请刷新后重新确认入库");
|
||||
return;
|
||||
}
|
||||
if(!numbers.isEmpty())fleet.lockExistingDispatchShells(numbers);
|
||||
for(var row:repository.findMany(numbers).stream().sorted(Comparator.comparing(com.baiyue.lastleg.dispatch.entity.DispatchAggregate::getId)).toList())repository.lock(row.getDispatchNo());
|
||||
appointments.lock(numbers);
|
||||
TransactionSynchronizationManager.bindResource(locksKey,Set.copyOf(numbers));
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
/** 事务完成后清除本次收货持锁范围。 */
|
||||
@Override public void afterCompletion(int status) { TransactionSynchronizationManager.unbindResourceIfPossible(locksKey); }
|
||||
});
|
||||
}
|
||||
|
||||
/** 实收确认、原排车数量及预约交接随同一收货事务提交或回滚。 */
|
||||
@Override public void complete(List<Long> planIds, String operator, LocalDateTime at) {
|
||||
var numbers=repository.receiptDispatches(planIds);
|
||||
@SuppressWarnings("unchecked") var locked=(Set<String>)TransactionSynchronizationManager.getResource(locksKey);
|
||||
if(locked==null || !locked.containsAll(numbers))throw conflict("收货前未锁齐原排车,请刷新后重新确认入库");
|
||||
for(var number:numbers) {
|
||||
var row=repository.lock(number);
|
||||
if(row==null || !"ACTIVE".equals(row.getStatus()))continue;
|
||||
var items=repository.cargo(row.getId());
|
||||
var pending=items.stream().filter(i->planIds.contains(i.getPlanId()) && i.getWarehousePalletId()==null).toList();
|
||||
if(pending.isEmpty())continue;
|
||||
if(!Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()))throw conflict("原排车已进入出货后阶段,无法确认实收交接");
|
||||
boolean preserveReview=workflow.currentReceiptBasis(row);
|
||||
var received=new LinkedHashMap<DispatchItem,DispatchPalletCandidate>();
|
||||
var empty=new ArrayList<DispatchItem>();
|
||||
for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
|
||||
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
|
||||
if(sources.confirmedEmptyPlan(item.getCustomerId(),item.getPlanPalletId(),item.getPlanId(),row.getOverseasWarehouseId())) {
|
||||
empty.add(item);
|
||||
continue;
|
||||
}
|
||||
var actual=DispatchReceivedPlan.resolve(row.getOverseasWarehouseId(),item,sources)
|
||||
.orElseThrow(()->conflict("实收托盘与原排车来源或占用不一致,请核对后确认入库"));
|
||||
received.put(item,actual);
|
||||
}
|
||||
for(var entry:received.entrySet()) {
|
||||
entry.getKey().setWarehousePalletId(entry.getValue().warehousePalletId());
|
||||
entry.getKey().setPackageCount(entry.getValue().packageCount());
|
||||
// 原预排的均分重量体积在确认入库时一并交接;未知值也必须覆盖旧计划快照。
|
||||
entry.getKey().setGrossWeightKg(entry.getValue().grossWeightKg());
|
||||
entry.getKey().setVolumeCbm(entry.getValue().volumeCbm());
|
||||
}
|
||||
for(var item:received.keySet().stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
|
||||
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
|
||||
var fresh=sources.confirmedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(item,fresh.get(0))
|
||||
|| !Objects.equals(item.getWarehousePalletId(),fresh.get(0).warehousePalletId())
|
||||
|| !Objects.equals(item.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请重新确认入库");
|
||||
}
|
||||
// 先释放空预排的逻辑占用;共享实托仍有成员时保留其物理排他。
|
||||
if(!empty.isEmpty()) {
|
||||
var owner=new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null);
|
||||
var original=physicalClaims.lockOwner(owner);
|
||||
for(var item:empty) {
|
||||
if(picking.picks(item.getCustomerId(),row.getId()).stream().anyMatch(p->Objects.equals(p.getDispatchItemId(),item.getId())))
|
||||
throw conflict("空托盘已有真实拣货,不能按短少移除");
|
||||
if(sources.release(row.getId(),item,at)!=1)throw conflict("空托盘原逻辑占用已变化");
|
||||
}
|
||||
physicalClaims.releaseIfUnused(owner,original.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock::getId).toList(),at);
|
||||
}
|
||||
var pallets=received.keySet().stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList();
|
||||
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null),pallets,at);
|
||||
for(var entry:received.entrySet()) {
|
||||
var item=entry.getKey();var actual=entry.getValue();
|
||||
if(sources.materialize(row.getId(),item.getCustomerId(),item.getPlanPalletId(),actual.warehousePalletId(),physical.get(actual.warehousePalletId()),at)!=1)
|
||||
throw conflict("原排车托盘占用已变化,入库交接未保存");
|
||||
item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED");
|
||||
if(picking.materialize(item)!=1)throw conflict("原排车逐托实收数量未保存");
|
||||
}
|
||||
appointments.complete(row.getId(),pending.stream().map(i->new InboundDispatchReceiptGateway.Item(i.getId(),i.getCustomerId(),i.getPlanPalletId(),empty.contains(i)?0:i.getPackageCount())).toList());
|
||||
for(var item:empty)if(repository.removeItem(item.getId())!=1)throw conflict("空托盘原排车明细已变化");
|
||||
workflow.saveReceivedCargo(row,operator,at,preserveReview);
|
||||
}
|
||||
}
|
||||
|
||||
/** 来源变化必须使收货交接整体失败,不留下半份运输事实。 */
|
||||
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT,message); }
|
||||
}
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
|
||||
import com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind;
|
||||
import com.baiyue.lastleg.fleet.photo.port.FleetResourcePhotoPort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode.MASTER;
|
||||
|
||||
/** 档案照片只在缺少本车照片时带入,复制为本车证据以冻结历史与文件权限。 */
|
||||
@Service
|
||||
public class DispatchMasterPhotoService {
|
||||
private final FleetResourcePhotoPort photos;
|
||||
private final DispatchEvidenceGateway evidence;
|
||||
/** 只读主档并通过本车文件网关创建证据。 */
|
||||
public DispatchMasterPhotoService(FleetResourcePhotoPort photos,DispatchEvidenceGateway evidence) {
|
||||
this.photos=photos; this.evidence=evidence;
|
||||
}
|
||||
/** 保存前补入已有档案照片;临时资源和已有当趟照片保持原样。 */
|
||||
public Resources populate(long dispatchId,String operator,Resources value) {
|
||||
var v=value.vehicle(); var d=value.driver();
|
||||
if(v!=null && v.mode()==MASTER && v.vehicleId()!=null) {
|
||||
var ids=populate(dispatchId,operator,Kind.VEHICLE,v.vehicleId(),v.evidenceFileIds());
|
||||
v=new VehicleSnapshot(v.mode(),v.vehicleId(),v.plate(),v.carrierName(),v.vehicleStatus(),v.capacity(),ids);
|
||||
}
|
||||
if(d!=null && d.mode()==MASTER && d.driverId()!=null && d.evidence()!=null) {
|
||||
var e=d.evidence();
|
||||
var ids=populate(dispatchId,operator,Kind.DRIVER,d.driverId(),e.photoFileIds());
|
||||
d=new DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(),new DriverEvidence(e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt(),ids,e.masterLicenseFileId()));
|
||||
}
|
||||
return new Resources(v,d,value.maintenance());
|
||||
}
|
||||
/** 只复制已经由档案服务确认的图片,上传失败使业务事务整体失败。 */
|
||||
public List<Long> populate(long dispatchId,String operator,Kind kind,long resourceId,List<Long> existing) {
|
||||
if(existing!=null && !existing.isEmpty())return List.copyOf(existing);
|
||||
var current=photos.current(kind,resourceId);
|
||||
if(current.photo()==null)return List.of();
|
||||
var source=photos.download(kind,resourceId,Long.parseLong(current.photo().fileId()));
|
||||
var file=new SnapshotFile(source.metadata().fileName(),source.metadata().contentType(),source.content());
|
||||
var copied=evidence.upload(dispatchId,kind==Kind.VEHICLE?DispatchEvidenceGateway.Kind.VEHICLE:DispatchEvidenceGateway.Kind.DRIVER_PHOTO,operator,file);
|
||||
return List.of(copied.fileId());
|
||||
}
|
||||
/** 只供服务间复制已核验图片,不接受任意磁盘路径。 */
|
||||
private record SnapshotFile(String filename,String contentType,byte[] bytes) implements MultipartFile {
|
||||
/** 上传部件固定使用文件字段。 */
|
||||
@Override public String getName() { return "file"; }
|
||||
/** 保留已核验主档文件名。 */
|
||||
@Override public String getOriginalFilename() { return filename; }
|
||||
/** 沿用已核验图片类型。 */
|
||||
@Override public String getContentType() { return contentType; }
|
||||
/** 拒绝空图片。 */
|
||||
@Override public boolean isEmpty() { return bytes.length==0; }
|
||||
/** 使用实际读取的字节长度。 */
|
||||
@Override public long getSize() { return bytes.length; }
|
||||
/** 返回本次复制的图片内容。 */
|
||||
@Override public byte[] getBytes() { return bytes; }
|
||||
/** 提供上传流,不读取磁盘文件。 */
|
||||
@Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); }
|
||||
/** 实现文件上传协议要求的输出方法。 */
|
||||
@Override public void transferTo(File dest) throws IOException { Files.write(dest.toPath(),bytes); }
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -16,6 +16,8 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
@Service
|
||||
public class DispatchPdaQueryService {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository sourceOrigins;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository sources;
|
||||
@jakarta.annotation.Resource private DispatchService sourceRules;
|
||||
private final DispatchPdaReadRepository repository;private final DispatchAggregateService workflow;
|
||||
/** 共用主流程资格,PDA不维护第二套版本和阶段。 */
|
||||
public DispatchPdaQueryService(DispatchPdaReadRepository repository,DispatchAggregateService workflow){this.repository=repository;this.workflow=workflow;}
|
||||
@@ -28,7 +30,7 @@ public class DispatchPdaQueryService {
|
||||
require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号");
|
||||
var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return assemble(actor,rows).get(0);
|
||||
}
|
||||
/** 按收货已确认的实托批量读取;未交接的原PLAN不推算实收仓位或箱数。 */
|
||||
/** 已绑定实托批量读取;尚未绑定的原PLAN仅在本次只读快照内核对自然实收。 */
|
||||
private List<DispatchViews.PdaTask> assemble(Actor actor,List<DispatchAggregate> roots) {
|
||||
if(roots.isEmpty())return List.of();if(roots.size()>200)throw new BizException(ResultCode.BAD_REQUEST,"作业超过完整读取上限,请按阶段筛选或扫描具体排车单号");
|
||||
var ids=roots.stream().map(DispatchAggregate::getId).toList();var cargo=repository.items(ids).stream().collect(Collectors.groupingBy(DispatchItem::getDispatchOrderId));
|
||||
@@ -59,6 +61,8 @@ public class DispatchPdaQueryService {
|
||||
var marks=Arrays.stream(Objects.requireNonNullElse(item.getCartonMarks(),"").split(",")).map(String::trim).filter(s->!s.isEmpty()).collect(Collectors.toSet());
|
||||
var sourcePositions=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream()
|
||||
.map(p->new DispatchViews.Position(p.getId(),p.getBalanceId(),n(p.getAvailablePackages()),n(p.getAllocatedPackages()),n(p.getPickedPackages()))).toList();
|
||||
if(item.getWarehousePalletId()==null && item.getPlanPalletId()!=null)
|
||||
sourcePositions=receivedPlanPositions(actor,item,items);
|
||||
if(normalRelabel(item)) {
|
||||
var contributions=byItem.getOrDefault(item.getId(),List.of());var ownPositions=new ArrayList<DispatchViews.Position>();
|
||||
if(contributions.isEmpty() || contributions.stream().mapToLong(s->n(s.getPackageCount())).sum()!=n(item.getPackageCount()))sourceValid=false;
|
||||
@@ -91,6 +95,14 @@ public class DispatchPdaQueryService {
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
/** 预排首次扫码前只解析原计划实收仓位,不在GET补写实托;真正绑定仍由pick锁内重验。 */
|
||||
private List<DispatchViews.Position> receivedPlanPositions(Actor actor,DispatchItem item,List<DispatchItem> items) {
|
||||
var received=DispatchReceivedPlan.resolve(actor.boundWarehouseId(),item,items,sources,sourceRules);
|
||||
if(received.isEmpty())return List.of();
|
||||
// 只修改本次投影对象,不在GET更新排车、版本或库存。
|
||||
item.setPackageCount(received.get().source().packageCount());
|
||||
return received.get().positions();
|
||||
}
|
||||
/** 仓和操作者均来自受信身份,不接收请求自报。 */
|
||||
private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");}
|
||||
/** 缺失计数不作为业务标识使用。 */
|
||||
|
||||
+43
-2
@@ -12,6 +12,7 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import java.time.*;
|
||||
@@ -23,8 +24,10 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
/** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */
|
||||
@Service
|
||||
public class DispatchPickingService {
|
||||
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
|
||||
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
|
||||
@jakarta.annotation.Resource private DispatchService sourceRules;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.FleetTripExecutionService fleet;
|
||||
private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources;
|
||||
private final DispatchPickingRepository picking;private final DispatchPickOrderAttributionService attribution;
|
||||
private final InventoryCommandService inventory;private final ReturnRelabelDispatchStock returns;
|
||||
@@ -50,8 +53,7 @@ public class DispatchPickingService {
|
||||
var currentItems=repository.cargo(row.getId());
|
||||
var item=currentItems.stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车货物明细不存在"));
|
||||
if(!Objects.equals(item.getOverseasWarehouseId(),actor.boundWarehouseId()))throw conflict("本货物不属于当前绑定仓");
|
||||
if(item.getWarehousePalletId()==null)throw conflict("该托盘尚未完成收货交接,请先确认入库后再拣货");
|
||||
relabelSources.validateFrozen(row,currentItems,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),currentItems));
|
||||
materialize(row,item,actor,currentItems);
|
||||
var task=picking.task(row.getId());var at=LocalDateTime.ofInstant(Instant.now().truncatedTo(ChronoUnit.MILLIS),ZoneOffset.UTC);
|
||||
if(task==null) {
|
||||
task=new DispatchPdaTask();task.setId(IdWorker.getId());task.setDispatchOrderId(row.getId());
|
||||
@@ -101,6 +103,45 @@ public class DispatchPickingService {
|
||||
new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Picked(itemId,request.positionId(),returnCartonId,request.packages(),before+request.packages(),complete),false,false);
|
||||
commands.append(row.getId(),actor,identity,result);return result;
|
||||
}
|
||||
/** 首拣将本车已实收原计划按真实整托数量绑定,保持原审核身份并与扣货同事务回滚。 */
|
||||
private void materialize(com.baiyue.lastleg.dispatch.entity.DispatchAggregate row,DispatchItem item,Actor actor,List<DispatchItem> items) {
|
||||
long dispatch=row.getId();
|
||||
if(item.getWarehousePalletId()!=null) {
|
||||
relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));return;
|
||||
}
|
||||
var received=new LinkedHashMap<DispatchItem,DispatchReceivedPlan.Fact>();
|
||||
for(var candidate:items)DispatchReceivedPlan.resolve(actor.boundWarehouseId(),candidate,items,sources,sourceRules)
|
||||
.ifPresent(fact->received.put(candidate,fact));
|
||||
if(!received.containsKey(item))throw conflict("该托盘尚未完成可拣实收,或原来源、目的地、库存占用已变化,请刷新核对");
|
||||
// 同车同实托的多个原计划一次绑定,避免首拣把其他已审核成员误判为新增货物。
|
||||
for(var entry:received.entrySet()) {
|
||||
entry.getKey().setWarehousePalletId(entry.getValue().source().warehousePalletId());
|
||||
entry.getKey().setPackageCount(entry.getValue().source().packageCount());
|
||||
}
|
||||
relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));
|
||||
for(var candidate:received.keySet().stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
|
||||
if(sources.lockPlan(candidate.getCustomerId(),candidate.getPlanPalletId())==null)throw conflict("原计划来源已变化");
|
||||
var fresh=sources.selectedPlan(candidate.getCustomerId(),candidate.getPlanPalletId());
|
||||
if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(candidate,fresh.get(0))
|
||||
|| !Objects.equals(candidate.getWarehousePalletId(),fresh.get(0).warehousePalletId())
|
||||
|| !Objects.equals(candidate.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请刷新后重新扫码");
|
||||
}
|
||||
var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);
|
||||
var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().sorted().toList();
|
||||
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at);
|
||||
for(var entry:received.entrySet()) {
|
||||
var candidate=entry.getKey();var actual=entry.getValue().source();
|
||||
try{if(sources.materialize(dispatch,candidate.getCustomerId(),candidate.getPlanPalletId(),actual.warehousePalletId(),physical.get(actual.warehousePalletId()),at)!=1)throw conflict("原计划来源占用已变化");}
|
||||
catch(DuplicateKeyException occupied){throw conflict("实托已经被其他排车占用");}
|
||||
candidate.setPalletNo(actual.palletNo());candidate.setSourceStage("RECEIVED");
|
||||
if(picking.materialize(candidate)!=1)throw conflict("本来源实际实托未保存");
|
||||
}
|
||||
row.setPackageCount(Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum()));
|
||||
var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop(
|
||||
i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key()))
|
||||
.collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
|
||||
fleet.updateDispatchCargo(row.getDispatchNo(),row.getPackageCount(),stops);
|
||||
}
|
||||
/** 库存幂等键分别标识一次实际动作,业务单号始终使用本车号。 */
|
||||
private static InventoryCommandRequests.Move move(DispatchCommands.Pick request,String dispatch,String reference,String key){return new InventoryCommandRequests.Move(request.positionId(),request.packages(),reference,dispatch,null,key,request.remark());}
|
||||
/** 不可变扫码证据序列化失败随根命令回滚。 */
|
||||
|
||||
+27
-5
@@ -2,23 +2,45 @@ package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType;
|
||||
|
||||
/** 预排来源只冻结货物身份;自然实收的逐托箱数在确认入库时由真实库存提供。 */
|
||||
/** 预排来源只冻结货物身份;自然实收的逐托箱数由真实库存提供,查询和首拣共用此规则。 */
|
||||
final class DispatchReceivedPlan {
|
||||
/** 原客户、计划及目的地保持一致,数量直接取确认入库的逐票实收。 */
|
||||
static Optional<DispatchPalletCandidate> resolve(String warehouse, DispatchItem item, DispatchSourceRepository sources) {
|
||||
record Fact(DispatchPalletCandidate source, List<DispatchViews.Position> positions) { }
|
||||
|
||||
/** 仅解析原车完整来源,禁止把新客户、新订单或冻结货物混入实收数量。 */
|
||||
static Optional<Fact> resolve(String warehouse, DispatchItem item, List<DispatchItem> items,
|
||||
DispatchSourceRepository sources, DispatchService rules) {
|
||||
if(item.getWarehousePalletId()!=null || item.getPlanPalletId()==null
|
||||
|| !Objects.equals(warehouse,item.getOverseasWarehouseId()))return Optional.empty();
|
||||
var rows=sources.confirmedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(!sources.receivedPlanCompleted(item.getCustomerId(),item.getPlanPalletId()))return Optional.empty();
|
||||
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(rows.size()!=1)return Optional.empty();
|
||||
var actual=rows.get(0);
|
||||
if(!sameIdentity(item,actual) || actual.packageCount()==null || actual.packageCount()<=0)return Optional.empty();
|
||||
var original=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
|
||||
if(original.isEmpty() || !new HashSet<>(original).containsAll(
|
||||
sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return Optional.empty();
|
||||
return Optional.of(actual);
|
||||
var group=rules.selectionGroup(warehouse,new DispatchCommands.CargoSelection(
|
||||
SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null));
|
||||
if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return Optional.empty();
|
||||
for(var member:group.sources()) {
|
||||
var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null);
|
||||
if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId()))return Optional.empty();
|
||||
// 原计划的估算箱数不限制实收;已经绑定或换标的冻结贡献仍严格核对。
|
||||
if((selected.getWarehousePalletId()!=null || selected.getPlanPalletId()==null)
|
||||
&& (selected.getPackageCount()==null || selected.getPackageCount().longValue()!=member.packages()))return Optional.empty();
|
||||
}
|
||||
var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false);
|
||||
if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),warehouse)
|
||||
|| !Objects.equals(p.palletId(),actual.warehousePalletId()) || p.holdPackages()!=0
|
||||
|| p.allocatedPackages()!=0 || p.pickedPackages()!=0)
|
||||
|| stock.stream().mapToLong(p->p.availablePackages()).sum()!=actual.packageCount())return Optional.empty();
|
||||
return Optional.of(new Fact(actual,stock.stream().filter(p->p.availablePackages()>0)
|
||||
.map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList()));
|
||||
}
|
||||
|
||||
/** 箱数允许随自然实收变化,客户、原计划、起运仓和目的地必须保持原身份。 */
|
||||
|
||||
-12
@@ -394,18 +394,6 @@ public class DispatchService {
|
||||
if(!pallets.isEmpty())palletSerial.lock(pallets);
|
||||
}
|
||||
|
||||
/** 关联预约只锁原订单、共同实托及计划托盘,不要求尚未发生的实收库存。 */
|
||||
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public void lockAppointmentSources(String warehouse,List<DispatchItem> items) {
|
||||
var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toCollection(TreeSet::new));
|
||||
var orders=executionOrders(warehouse,items,pallets);
|
||||
orders.forEach((id,customer)->{if(wholeSources.lockOrder(customer,id)==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"本车真实来源订单已缺失或暂扣");});
|
||||
palletSerial.lock(List.copyOf(pallets));
|
||||
if(!orders.equals(executionOrders(warehouse,items,pallets)))throw new ResponseStatusException(HttpStatus.CONFLICT,"本车逐票来源已变化,请重新核对");
|
||||
for(var item:items.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList())
|
||||
if(wholeSources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"本车计划托盘已缺失或归属变化");
|
||||
}
|
||||
|
||||
/** 执行沿用全车申请/订单→共同实托串行顺序;库存变化不重新解释原冻结贡献。 */
|
||||
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public RelabelDispatchSourcePort.Snapshot lockExecutionSources(String warehouse,List<DispatchItem> items) {
|
||||
|
||||
-15
@@ -36,7 +36,6 @@ public class TransportTaskService {
|
||||
private final TripOperationsEvidenceGateway files;
|
||||
private final ObjectMapper json;
|
||||
@jakarta.annotation.Resource private TransportReviewPhotos reviewPhotos;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchMasterPhotoService masterPhotos;
|
||||
/** 复用已有资格、占用、原单及凭证能力。 */
|
||||
public TransportTaskService(DispatchAggregateRepository roots,TransportTaskRepository links,TransportTaskProjection projection,
|
||||
FleetTripRepository tripRepo,FleetTripService trips,FleetPickupService pickups,FleetPickupRepository pickupFacts,
|
||||
@@ -120,20 +119,6 @@ public class TransportTaskService {
|
||||
if(!Objects.equals(trip.getRecordVersion(),request.recordVersion()) || request.dispatchRecordVersion()==null
|
||||
|| !Objects.equals(row.getRecordVersion(),request.dispatchRecordVersion()))throw conflict("排车或审核信息已变化,请刷新后重试");
|
||||
if(!"PLANNED".equals(trip.getStatus()) || pickups.tasks(trip.getTripNo()).stream().anyMatch(task->!"ASSIGNED".equals(task.status())))throw conflict("已取货的任务不能更换资源照片");
|
||||
// 主档照片缺省带入本车;显式本趟照片仍由原归属校验复核。
|
||||
var supplied=request.reviewPhotos();
|
||||
var vehiclePhotos=masterPhotos.populate(row.getId(),operator,com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.VEHICLE,
|
||||
request.vehicleId(),supplied==null?List.of():supplied.vehicleFileIds());
|
||||
var driverPhotos=masterPhotos.populate(row.getId(),operator,com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.DRIVER,
|
||||
request.driverId(),supplied==null?List.of():supplied.driverPhotoFileIds());
|
||||
var license=request.masterLicenseFileId();
|
||||
if(license==null && (supplied==null || supplied.licenseFileIds().isEmpty())) {
|
||||
var driver=drivers.detail(request.driverId(),null);
|
||||
if(driver.licenseFileId()!=null)license=Long.valueOf(driver.licenseFileId());
|
||||
}
|
||||
request=new TransportTaskModels.Assign(request.recordVersion(),request.vehicleId(),request.driverId(),request.reason(),
|
||||
request.dispatchRecordVersion(),new com.baiyue.lastleg.dispatch.port.DispatchContracts.ReviewAttachments(
|
||||
vehiclePhotos,driverPhotos,supplied==null?List.of():supplied.licenseFileIds()),license);
|
||||
photos=reviewPhotos.validate(row,trip,request);
|
||||
}
|
||||
trips.assign(trip.getTripNo(),operator,new FleetTripModels.Assign(request.recordVersion(),request.vehicleId(),request.driverId(),request.reason()));
|
||||
|
||||
+1
-4
@@ -27,10 +27,7 @@ public final class OutsourcedBusinessModels {
|
||||
/** 固定外配入口不接收浏览器作用域或其他协议字段。 */
|
||||
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
|
||||
}
|
||||
public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId,
|
||||
@Pattern(regexp="[1-9][0-9]*") String replaceFileId) {
|
||||
/** 未指定替换目标时追加装车照片。 */
|
||||
public BindPhoto(String recordVersion,String idempotencyKey,String fileId) { this(recordVersion,idempotencyKey,fileId,null); }
|
||||
public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {
|
||||
/** 绑定只接受原版本、原键和File编号,不能提交车辆归属。 */
|
||||
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
|
||||
}
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ public class OutsourcedBusinessService {
|
||||
/** 原File编号由共享绑定事务校验,不创建第二份附件或写原主单。 */
|
||||
public Detail bind(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,BindPhoto request) {
|
||||
if(request==null)throw bad("照片绑定请求缺失");
|
||||
return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId(),request.replaceFileId()),SCOPE));
|
||||
return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId()),SCOPE));
|
||||
}
|
||||
/** 当前或历史引用读取始终限定原车、照片框和外配权限。 */
|
||||
public OwnedBusinessPhotoService.Download content(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
|
||||
|
||||
+1
-5
@@ -34,11 +34,7 @@ public final class OwnedBusinessModels {
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotNull @Valid Operations operations,
|
||||
@Size(max=1000) String reason) {}
|
||||
public record BindPhoto(@NotBlank String recordVersion,
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId,
|
||||
@Pattern(regexp="[1-9][0-9]*") String replaceFileId) {
|
||||
/** 未指定替换目标时追加装车照片。 */
|
||||
public BindPhoto(String recordVersion,String idempotencyKey,String fileId) { this(recordVersion,idempotencyKey,fileId,null); }
|
||||
}
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {}
|
||||
public record Gate(boolean allowed,String blockReason) {}
|
||||
public record Actions(Gate edit,Gate upload) {}
|
||||
public record Origin(String warehouseId,String warehouseCode,String warehouseName) {}
|
||||
|
||||
+1
-5
@@ -8,11 +8,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
public interface DispatchLoadingPhotoPort {
|
||||
enum Slot { LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT }
|
||||
record Photo(Slot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {}
|
||||
record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId,
|
||||
@Pattern(regexp="[1-9][0-9]*") String replaceFileId) {
|
||||
/** 未指定替换目标时追加照片,每类最多五张。 */
|
||||
public BindPhoto(String recordVersion,String idempotencyKey,String fileId) { this(recordVersion,idempotencyKey,fileId,null); }
|
||||
}
|
||||
record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {}
|
||||
record State(String recordVersion,List<Photo> photos) {}
|
||||
record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String stage,boolean editable,boolean complete,List<Photo> photos) {}
|
||||
record Download(Photo photo,byte[] content) {}
|
||||
|
||||
+3
-4
@@ -77,9 +77,9 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort {
|
||||
seed.setCreatedBy(operator);seed.setUpdatedBy(operator);seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt());
|
||||
var row=repository.lock(seed);
|
||||
if(row==null || row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("装车资料已被其他操作更新,请刷新后核对");
|
||||
var saved=new OwnedBusinessModels.Photo(originalSlot,originalSlot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString());
|
||||
var photos=OwnedBusinessPhotoBindings.bind(projection.photos(row),saved,request.replaceFileId());
|
||||
row.setPhotosJson(codec.write(photos));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now()));
|
||||
var photos=new EnumMap<PhotoSlot,OwnedBusinessModels.Photo>(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p);
|
||||
photos.put(originalSlot,new OwnedBusinessModels.Photo(originalSlot,originalSlot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()));
|
||||
row.setPhotosJson(codec.write(List.copyOf(photos.values())));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now()));
|
||||
if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败");
|
||||
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(id);history.setRecordVersion(row.getRecordVersion());
|
||||
history.setAction("BIND_LOADING_DOCUMENT");history.setOperatorId(operator);history.setReason("保存装车照片 "+(slot.ordinal()+1));history.setOccurredAt(row.getUpdatedAt());
|
||||
@@ -104,7 +104,6 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort {
|
||||
private void validate(Slot slot,BindPhoto request) {
|
||||
slot(slot);if(request==null)throw bad("照片绑定请求缺失");codec.key(request.idempotencyKey());codec.version(request.recordVersion());
|
||||
if(codec.version(request.fileId())<=0)throw bad("文件编号无效");
|
||||
if(request.replaceFileId()!=null && codec.version(request.replaceFileId())<=0)throw bad("待替换文件编号无效");
|
||||
}
|
||||
/** 账本只消费可信操作者;平台/PDA权限在排车入口复核。 */
|
||||
private OwnedBusinessModels.Actor operator(String operator){return new OwnedBusinessModels.Actor(operator,operator,Set.of());}
|
||||
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
||||
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Photo;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
|
||||
|
||||
/** 照片按文件追加或精确替换,禁止按类别压缩而丢失其他已保存图片。 */
|
||||
final class OwnedBusinessPhotoBindings {
|
||||
/** 工具类仅提供照片引用合并。 */
|
||||
private OwnedBusinessPhotoBindings() {}
|
||||
/** 装车每类最多五张;油耗仍为单张,替换目标必须在当前同类引用中。 */
|
||||
static List<Photo> bind(List<Photo> existing,Photo photo,String replaceFileId) {
|
||||
var result=new ArrayList<>(existing);
|
||||
// 油耗沿原单张替换协议,不能因装车改多图而改变其他入口的保存行为。
|
||||
if(replaceFileId==null && "FUEL".equals(photo.category()))
|
||||
replaceFileId=existing.stream().filter(p->p.slot()==photo.slot()).map(Photo::fileId).findFirst().orElse(null);
|
||||
if(replaceFileId!=null) {
|
||||
int index=-1;
|
||||
for(int i=0;i<result.size();i++)if(result.get(i).slot()==photo.slot() && result.get(i).fileId().equals(replaceFileId))index=i;
|
||||
if(index<0)throw conflict("待替换照片已变化,请刷新后核对");
|
||||
result.remove(index);
|
||||
if(result.stream().anyMatch(p->p.fileId().equals(photo.fileId())))throw bad("照片已绑定,不能重复保存");
|
||||
result.add(index,photo);
|
||||
} else {
|
||||
if(result.stream().anyMatch(p->p.fileId().equals(photo.fileId())))throw bad("照片已绑定,不能重复保存");
|
||||
int limit="LOADING".equals(photo.category())?5:1;
|
||||
if(result.stream().filter(p->p.slot()==photo.slot()).count()>=limit)throw conflict("该类照片最多"+limit+"张,请替换已有照片");
|
||||
result.add(photo);
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -69,20 +69,20 @@ public class OwnedBusinessRecordService {
|
||||
var row=lock(source,actor);old=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||
if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);}
|
||||
requireVersion(row,version);
|
||||
var saved=new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),
|
||||
verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString());
|
||||
var photos=OwnedBusinessPhotoBindings.bind(projection.photos(row),saved,request.replaceFileId());
|
||||
row.setPhotosJson(codec.write(photos));
|
||||
var photos=new EnumMap<PhotoSlot,Photo>(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p);
|
||||
photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),
|
||||
verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()));
|
||||
row.setPhotosJson(codec.write(List.copyOf(photos.values())));
|
||||
return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot),scope);
|
||||
}
|
||||
/** 操作记录使用准确中文槽位,不向用户展示内部枚举。 */
|
||||
private String photoReason(PhotoSlot slot) {
|
||||
return switch(slot) {
|
||||
case LOADING_SCENE -> "保存司机驾驶证照片";
|
||||
case LOADING_LIST -> "保存车牌照片";
|
||||
case PRE_DEPARTURE_CHECK -> "保存装车完成照片";
|
||||
case DRIVER_LICENSE -> "保存封车照片";
|
||||
case RECEIPT -> "保存BOL司机签字照片";
|
||||
case LOADING_SCENE -> "保存装车现场照片";
|
||||
case LOADING_LIST -> "保存装车清单照片";
|
||||
case PRE_DEPARTURE_CHECK -> "保存出车前检查照片";
|
||||
case DRIVER_LICENSE -> "保存驾驶证附件照片";
|
||||
case RECEIPT -> "保存签收凭证照片";
|
||||
case FUEL_START -> "保存起始油耗表盘照片";
|
||||
case FUEL_END -> "保存结束油耗表盘照片";
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user