Compare commits

...
24 Commits
Author SHA1 Message Date
ouyangxq f4362f6dfd fix(ci): reject TEST revisions without memory protection 2026-11-06 13:59:34 +08:00
liuyihe 9c8273d9a0 Merge remote-tracking branch 'origin/test' into test 2026-11-06 13:34:06 +08:00
liuyihe e995a87b0e 提交 2026-11-06 13:33:37 +08:00
ouyangxq 51c3132195 fix(deploy): enforce TEST service memory budgets 2026-11-06 13:31:25 +08:00
liuyihe 9e99658bfa 提交 2026-11-06 10:41:49 +08:00
liuyihe 44202a7bff 提交 2026-11-05 17:46:30 +08:00
liuyihe ef2ac1eafd Merge remote-tracking branch 'origin/test' into test 2026-11-05 16:52:53 +08:00
liuyihe 08ea663334 提交 2026-11-05 16:52:35 +08:00
ouyangxq 8809801070 Merge remote-tracking branch 'origin/test' into test 2026-11-05 16:30:27 +08:00
ouyangxq 497544e9fd fix: restore customer visit uploads and guard fee publication 2026-11-05 16:30:27 +08:00
liuyihe 83c3b02d96 Merge remote-tracking branch 'origin/test' into test 2026-11-05 16:04:51 +08:00
liuyihe d1ade7e9dd 提交 2026-11-05 16:04:10 +08:00
ouyangxq 97c80574df Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-11-05 14:53:54 +08:00
ouyangxq b063d7866c 优化 2026-11-05 14:53:52 +08:00
liuyihe 5a8918e2e4 提交 2026-11-05 11:55:23 +08:00
liuyihe 94a36b8c4c 提交 2026-11-04 19:26:27 +08:00
ouyangxq 51291b4c27 优化 2026-11-04 18:08:19 +08:00
liuyihe 0993dd446e 提交 2026-11-04 15:55:55 +08:00
ouyangxq fbdf58c23c 补齐海外仓和私人地址正常及短少收货真实验收 2026-11-04 14:23:41 +08:00
ouyangxq 40475388e1 支持私人地址按实际收货生成库存凭证 2026-11-04 13:55:24 +08:00
ouyangxq 22e8939f1f 修复私人地址逐箱收货的目的地匹配 2026-11-04 13:45:59 +08:00
ouyangxq a01598bd21 允许私人地址按独立托盘生成海外入库计划 2026-11-04 13:35:23 +08:00
ouyangxq 0d8e64c42c 支持私人地址装柜后按普通海外收货交接 2026-11-04 12:45:05 +08:00
ouyangxq 8b56af2cae 统一人工报价时间精度以保证原键精确重放 2026-11-04 12:28:47 +08:00
155 changed files with 4337 additions and 358 deletions
+8
View File
@@ -125,6 +125,14 @@ pipeline {
git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}"
git checkout --detach "${RESOLVED_SOURCE_COMMIT}"
test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")"
# TEST 重发旧提交也须包含限额文件及部署入口,避免重建时恢复无限制容器。
if [ "$RESOLVED_ENVIRONMENT" = "test" ]; then
if ! git cat-file -e "${RESOLVED_SOURCE_COMMIT}:deploy/docker-compose.test-limits.yml" 2>/dev/null ||
! git grep -q -F 'docker-compose.test-limits.yml' "$RESOLVED_SOURCE_COMMIT" -- deploy/scripts/deploy-built-service.sh; then
echo "目标 TEST 提交缺少完整内存保护,请选择包含限额修复的 test 提交" >&2
exit 1
fi
fi
if [ "$RESOLVED_ENVIRONMENT" = "production" ]; then
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
git merge-base --is-ancestor "$RESOLVED_SOURCE_COMMIT" origin/main || {
+3
View File
@@ -10,6 +10,9 @@ baiyue.file.storage-root: ${FILE_STORAGE_ROOT}
baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:}
baiyue:
file:
pda:
# 在目标 Nacos 命名空间填写公网 HTTPS 根地址,不含 /api;真实环境地址不提交仓库。
public-base-url: ""
export:
definitions:
CUSTOMER_LIST:
+65
View File
@@ -0,0 +1,65 @@
# 仅由 TEST 部署入口叠加,固定 JVM 堆并为线程、元空间和直接内存保留余量。
x-test-limits: &test-limits
mem_limit: 1536m
memswap_limit: 1536m
environment: &test-java-environment
JAVA_OPTS: "-XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
JAVA_TOOL_OPTIONS: "-Xms128m -Xmx768m"
services:
gateway:
<<: *test-limits
auth:
<<: *test-limits
iam:
<<: *test-limits
order:
<<: *test-limits
firstleg:
<<: *test-limits
mem_limit: 2g
memswap_limit: 2g
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms256m -Xmx1g"
finance:
<<: *test-limits
warehouse:
<<: *test-limits
warehouse-overseas-service:
<<: *test-limits
integration-service:
<<: *test-limits
audit-service:
<<: *test-limits
file:
<<: *test-limits
mem_limit: 3g
memswap_limit: 3g
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms1g -Xmx2g"
print:
<<: *test-limits
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1g"
lastleg-service:
<<: *test-limits
mem_limit: 3g
memswap_limit: 3g
environment:
<<: *test-java-environment
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1536m"
+8
View File
@@ -12,6 +12,7 @@ IMAGE_TAG="$3"
SERVICE="$4"
ENV_FILE="$5"
COMPOSE_FILE="deploy/docker-compose.yml"
TEST_LIMITS_FILE="deploy/docker-compose.test-limits.yml"
IMAGE="$IMAGE_REPOSITORY/$SERVICE:$IMAGE_TAG"
REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}"
PROJECT_NAME="firstleg-$DEPLOY_ENV"
@@ -40,6 +41,9 @@ case "${ALLOW_SAME_CONTRACT_ROLLBACK:-false}" in true|false) ;; *) exit 2 ;; esa
if [ "$DEPLOY_ENV" = "test" ] && [ "${TARGET_IS_LOCAL:-false}" != "true" ]; then
echo "TEST deploy must run on the target host" >&2; exit 2
fi
if [ "$DEPLOY_ENV" = "test" ] && [ ! -f "$TEST_LIMITS_FILE" ]; then
echo "TEST memory limits file missing: $TEST_LIMITS_FILE" >&2; exit 2
fi
[ -f "$ENV_FILE" ] || { echo "Environment file missing" >&2; exit 2; }
env_file_value() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'; }
for key in NACOS_SERVER_ADDR NACOS_NAMESPACE NACOS_USERNAME NACOS_PASSWORD REDIS_HOST REDIS_PORT REDIS_DATABASE REDIS_TOKEN_KEY_PREFIX INTERNAL_SERVICE_TOKEN JWT_RSA_JWK_SET JWT_CURRENT_KID ROCKETMQ_NAME_SERVER; do
@@ -74,7 +78,11 @@ local_registry_logout() {
if [ -n "${deploy_docker_config:-}" ]; then rm -rf -- "$deploy_docker_config"; fi
}
# TEST 的查询、部署和回滚共用同一份内存限额,缺失时由入口提前拒绝。
local_compose() {
if [ "$DEPLOY_ENV" = "test" ]; then
set -- -f "$TEST_LIMITS_FILE" "$@"
fi
docker compose \
--project-name "$PROJECT_NAME" \
--env-file "$ENV_FILE" \
+2
View File
@@ -1,5 +1,7 @@
# 单测试环境数据库空库初始化
`migrations/overseas/baiyue_lastleg/V38__allow_multi_order_delivery_returns.sql` 移除派送退回的旧全历史唯一索引,保留普通来源索引,支持同派送一次选择多张原订单各建异常。配套 Lastleg 批次行锁、总量校验及 Admin 多选;可重复执行,不改业务记录。已在本机应用并核对行数,远程 TEST 尚未执行。
`20261104-unify-utf8mb4-0900-ai-ci.sql` 是本机 MySQL 8.4.11 五个业务库的排序规则汇总脚本,覆盖 `baiyue_event_bridge`、`baiyue_event_bridge_overseas`、`baiyue_firstleg`、`baiyue_lastleg`、`baiyue_order`。脚本已在本机执行并核验 209 张表、1,644 个文本字段;其他环境执行前必须核对实际字段定义和唯一索引冲突,不能默认部署。脚本只收敛旧的 Unicode 不区分大小写排序规则,保留有意使用的 `utf8mb4_bin`。
退货仓租改为原托整托计费、余一箱仍计一托,现有阶梯不变。已有 Lastleg 库先执行 [整托计费结构增量](return-storage-rent-whole-pallet.sql) 增加 `ll_storage_rent_lot.charged_days`,再发布匹配的 Lastleg;源基线和海外空库基线已同步。实际出库事务推进已生成费用的截止库龄日,同托同计租日不重复收费。切换期间暂停相关出库、取消未消费预占,并逐单核对旧比例费用;脚本不修改既有费用、库存或资金。本次仅准备代码及脚本,未执行环境 SQL 或部署。
@@ -0,0 +1,10 @@
-- 同一派送可按多张原订单生成退回异常;活动批次由派送行锁和来源数量校验保护。
-- 只调整索引,不修改异常、签收、库存或资金事实;重复执行跳过已完成项。
USE baiyue_lastleg;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_exception_order' AND index_name = 'idx_ll_exception_delivery_return'),
'ALTER TABLE ll_exception_order ADD KEY idx_ll_exception_delivery_return (customer_id, delivery_order_id, exception_type)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_exception_order' AND index_name = 'uk_ll_exception_delivery_return'),
'ALTER TABLE ll_exception_order DROP INDEX uk_ll_exception_delivery_return', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -3093,7 +3093,7 @@ CREATE TABLE fl_loading_cabinet_evidence (
seal_no varchar(64) DEFAULT NULL COMMENT '带锁码照对应锁码',
remark varchar(1000) DEFAULT NULL COMMENT '证据备注',
status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID作废',
active_flag tinyint unsigned DEFAULT '1' COMMENT '有效记录为1,作废记录为NULL,保证每柜每类型仅一条有效事实',
active_flag tinyint unsigned DEFAULT '1' COMMENT '有效记录为1,作废记录为NULL,同类允许多张有效照片',
operation_key varchar(128) NOT NULL COMMENT '任务范围内幂等操作键',
request_hash char(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
void_reason varchar(500) DEFAULT NULL COMMENT '作废原因',
@@ -3109,7 +3109,7 @@ CREATE TABLE fl_loading_cabinet_evidence (
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key),
UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag),
KEY idx_cabinet_evidence_active (container_booking_id,evidence_type,active_flag),
KEY idx_cabinet_evidence_container (container_booking_id,status),
KEY idx_cabinet_evidence_seal (seal_no,active_flag),
CONSTRAINT fk_cabinet_evidence_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id),
@@ -49,6 +49,7 @@ enum BusinessFileCategory {
OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_CUSTOMER_VISIT(true, FormatPolicy.CUSTOMER_VISIT, Limits.CUSTOMER_VISIT, BusinessIdPolicy.POSITIVE_LONG),
LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
LOADING_LIST_CUSTOMS_RECEIPT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
LOADING_LIST_CUSTOMS_RESULT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
@@ -135,11 +136,12 @@ enum BusinessFileCategory {
return Long.toString(value);
}
enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF, PDF_IMAGE, IMAGE }
enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF, PDF_IMAGE, IMAGE, CUSTOMER_VISIT }
private enum BusinessIdPolicy { POSITIVE_LONG, ORDER_BATCH_NO }
private static final class Limits {
private static final long STANDARD = 10L * 1024 * 1024;
private static final long CUSTOMS = 50L * 1024 * 1024;
private static final long ORDER_IMPORT = 5L * 1024 * 1024;
private static final long CUSTOMER_VISIT = 20L * 1024 * 1024;
}
}
@@ -18,6 +18,7 @@ import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@Component
@@ -27,6 +28,8 @@ public class BusinessFileStorage {
private static final Map<String, String> TYPES = Map.of(
"text/plain", ".txt", "application/pdf", ".pdf",
"image/jpeg", ".jpg", "image/png", ".png");
private static final Set<String> CUSTOMER_VISIT_EXTENSIONS = Set.of(
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "jpg", "jpeg", "png", "webp");
@Value("${baiyue.file.storage-root}")
private Path configuredRoot;
@@ -46,6 +49,7 @@ public class BusinessFileStorage {
return store(tenantId, businessId, file, category.maxSizeBytes(), category.formatPolicy());
}
/** 按业务类别检查大小和格式,保存真实文件并返回存储结果。 */
private Stored store(long tenantId, String businessId, MultipartFile file, long maxSize,
BusinessFileCategory.FormatPolicy formatPolicy) {
if (file == null || file.isEmpty()) throw bad("附件不能为空");
@@ -55,6 +59,8 @@ public class BusinessFileStorage {
String extension;
if (formatPolicy == BusinessFileCategory.FormatPolicy.ARBITRARY) {
extension = "";
} else if (formatPolicy == BusinessFileCategory.FormatPolicy.CUSTOMER_VISIT) {
extension = customerVisitExtension(file);
} else if (formatPolicy == BusinessFileCategory.FormatPolicy.PDF) {
if (!"application/pdf".equals(type)) throw bad("附件仅支持PDF");
verifyContent(file, type);
@@ -85,6 +91,17 @@ public class BusinessFileStorage {
}
}
/** 与回访入口保持相同扩展名约束,接受浏览器未提供 MIME 的 Office 资料。 */
private static String customerVisitExtension(MultipartFile file) {
String name = safeName(file);
int dot = name.lastIndexOf('.');
String extension = dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT);
if (!CUSTOMER_VISIT_EXTENSIONS.contains(extension)) {
throw bad("仅支持PDF、Office、JPG、PNG和WEBP文件");
}
return "." + extension;
}
private static void verifyXlsx(MultipartFile file) {
try (InputStream input = file.getInputStream()) {
byte[] prefix = input.readNBytes(4);
@@ -174,7 +174,8 @@ public class GenericXlsxExportRunner {
case STRING -> cell.setCellValue(safeString(String.valueOf(value), column.key(), warnedTruncatedColumns));
case INTEGER -> cell.setCellValue(new BigDecimal(String.valueOf(value)).longValueExact());
case DECIMAL -> cell.setCellValue(new BigDecimal(String.valueOf(value)).doubleValue());
case BOOLEAN -> cell.setCellValue(value instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(value)));
// 面向业务人员的是否字段统一显示中文,Provider 仍传输布尔类型。
case BOOLEAN -> cell.setCellValue((value instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(value))) ? "是" : "否");
case DATE -> { cell.setCellValue(LocalDate.parse(String.valueOf(value))); cell.setCellStyle(styles.date); }
case DATETIME -> { cell.setCellValue(LocalDateTime.parse(String.valueOf(value))); cell.setCellStyle(styles.dateTime); }
}
@@ -0,0 +1,161 @@
package com.baiyue.file.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
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.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class CustomerVisitBusinessFileTest {
private static final String TYPE = "OVERSEAS_CUSTOMER_VISIT";
private static final long MAX_SIZE = 20L * 1024 * 1024;
@TempDir Path root;
private BusinessFileStorage storage;
private FileObjectMapper files;
private InternalBusinessFileService service;
/** 初始化文件元数据映射,供真实归属查询使用。 */
@BeforeAll
static void initializeMapping() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "visit-files"),
FileObjectEntity.class);
}
/** 使用真实本地存储,隔离数据库写入。 */
@BeforeEach
void setUp() {
storage = new BusinessFileStorage();
ReflectionTestUtils.setField(storage, "configuredRoot", root);
storage.initializeRoot();
files = mock(FileObjectMapper.class);
service = new InternalBusinessFileService();
ReflectionTestUtils.setField(service, "files", files);
ReflectionTestUtils.setField(service, "storage", storage);
}
/** 页面允许的全部回访资料均可保存,并保持客户及回访记录的精确归属。 */
@ParameterizedTest
@CsvSource(delimiter = '|', value = {
"pdf|application/pdf", "doc|application/msword", "docx|application/octet-stream",
"xls|application/vnd.ms-excel", "xlsx|application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"ppt|application/vnd.ms-powerpoint", "pptx|application/vnd.openxmlformats-officedocument.presentationml.presentation",
"jpg|image/jpeg", "jpeg|image/jpeg", "png|image/png", "webp|image/webp"
})
void uploadsAllSupportedFormatsWithCustomerAndVisitOwnership(String extension, String contentType) {
var file = new MockMultipartFile("file", "回访资料." + extension, contentType, new byte[]{1, 2, 3});
when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> {
FileObjectEntity row = call.getArgument(0);
row.setId(801L);
return 1;
});
var metadata = service.upload(1001L, TYPE, "71", 9L, file);
assertThat(metadata.ownerScope()).isEqualTo("CUSTOMER");
assertThat(metadata.tenantId()).isEqualTo(1001L);
assertThat(metadata.businessType()).isEqualTo(TYPE);
assertThat(metadata.businessId()).isEqualTo("71");
assertThat(metadata.fileId()).isEqualTo(801L);
assertThat(metadata.fileName()).isEqualTo(file.getOriginalFilename());
assertThat(metadata.contentType()).isEqualTo(contentType);
assertThat(metadata.sizeBytes()).isEqualTo(3L);
var persisted = ArgumentCaptor.forClass(FileObjectEntity.class);
verify(files).insert(persisted.capture());
assertThat(storage.read(persisted.getValue().getObjectKey())).containsExactly(1, 2, 3);
}
/** MIME 缺失或扩展名大写不应阻断有效的 Office 回访资料。 */
@Test
void acceptsOfficeFileWithoutBrowserMimeType() {
var file = new MockMultipartFile("file", "回访记录.DOCX", null, new byte[]{1});
var stored = storage.store(1001L, "71", file, BusinessFileCategory.require(TYPE));
assertThat(stored.contentType()).isEqualTo("application/octet-stream");
assertThat(stored.objectKey()).endsWith(".docx");
}
/** 回访资料单独使用 20MiB 限制,不放宽其他业务附件的限制。 */
@Test
void enforcesNonemptyAndTwentyMibBoundary() {
var category = BusinessFileCategory.require(TYPE);
assertThatThrownBy(() -> storage.store(1001L, "71", new MockMultipartFile("file", new byte[0]), category))
.isInstanceOf(BizException.class).hasMessageContaining("不能为空");
var oversized = mock(MultipartFile.class);
when(oversized.isEmpty()).thenReturn(false);
when(oversized.getSize()).thenReturn(MAX_SIZE + 1);
assertThatThrownBy(() -> storage.store(1001L, "71", oversized, category))
.isInstanceOf(BizException.class).hasMessageContaining("20MB");
var boundary = new MockMultipartFile("file", "回访.doc", "application/msword", new byte[(int) MAX_SIZE]);
assertThat(storage.store(1001L, "71", boundary, category).size()).isEqualTo(MAX_SIZE);
assertThat(BusinessFileCategory.CUSTOMS_DOCUMENT.maxSizeBytes()).isEqualTo(10L * 1024 * 1024);
}
/** 不接受回访入口约定之外的文件或双扩展名可执行文件。 */
@ParameterizedTest
@ValueSource(strings = {"readme.txt", "archive.zip", "visit.pdf.exe", "visit"})
void rejectsUnsupportedFormatsBeforeMetadataIsWritten(String name) {
var file = new MockMultipartFile("file", name, "application/octet-stream", new byte[]{1});
assertThatThrownBy(() -> service.upload(1001L, TYPE, "71", 9L, file))
.isInstanceOf(BizException.class).hasMessageContaining("仅支持");
verifyNoInteractions(files);
}
/** 回访资料必须归属明确客户和有效回访记录。 */
@Test
void rejectsPlatformScopeAndInvalidVisitId() {
var file = new MockMultipartFile("file", "visit.pdf", "application/pdf", new byte[]{1});
assertThatThrownBy(() -> service.upload(0L, TYPE, "71", 9L, file))
.isInstanceOf(BizException.class).hasMessageContaining("必须归属明确客户");
assertThatThrownBy(() -> service.upload(1001L, TYPE, "0", 9L, file))
.isInstanceOf(BizException.class).hasMessageContaining("业务ID必须有效");
verifyNoInteractions(files);
}
/** 保存回访时按客户、类型、回访记录和文件 ID 校验,不能串用其他资料。 */
@Test
void metadataRequiresEveryOwnershipKey() {
var value = new FileObjectEntity();
value.setId(801L);
value.setTenantId(1001L);
value.setBusinessType(TYPE);
value.setBusinessId("71");
when(files.selectOne(any())).thenAnswer(call -> {
LambdaQueryWrapper<FileObjectEntity> query = call.getArgument(0);
assertThat(query.getSqlSegment()).contains("tenant_id", "business_type", "business_id", "id", "status", "deleted");
return query.getParamNameValuePairs().values().containsAll(
java.util.List.of(1001L, TYPE, "71", 801L, "ACTIVE", false)) ? value : null;
});
assertThat(service.metadata(1001L, TYPE, "71", 801L).fileId()).isEqualTo(801L);
assertThatThrownBy(() -> service.metadata(1002L, TYPE, "71", 801L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
assertThatThrownBy(() -> service.metadata(1001L, TYPE, "72", 801L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
assertThatThrownBy(() -> service.metadata(1001L, "OVERSEAS_EXCEPTION_EVIDENCE", "71", 801L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
assertThatThrownBy(() -> service.metadata(1001L, TYPE, "71", 802L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
}
}
@@ -48,7 +48,7 @@ class GenericXlsxExportRunnerTest {
}
@Test void writesRealTypedWorkbookAndCarriesSnapshotAcrossBatches() throws Exception {
List<ExportColumn> columns=List.of(new ExportColumn("text","文本",ExportColumnType.STRING,18),new ExportColumn("integer","整数",ExportColumnType.INTEGER,12),new ExportColumn("decimal","金额",ExportColumnType.DECIMAL,12),new ExportColumn("flag","布尔",ExportColumnType.BOOLEAN,10),new ExportColumn("date","日期",ExportColumnType.DATE,12),new ExportColumn("time","时间",ExportColumnType.DATETIME,20));
List<Map<String,Object>> firstRows=IntStream.range(0,100).mapToObj(index -> Map.<String,Object>of("text",index==0?"=SUM(1,2)":"row-"+index,"integer",12,"decimal","3.50","flag",true,"date","2026-08-03","time","2026-08-03T21:00:00")).toList();
List<Map<String,Object>> firstRows=IntStream.range(0,100).mapToObj(index -> Map.<String,Object>of("text",index==0?"=SUM(1,2)":"row-"+index,"integer",12,"decimal","3.50","flag",index % 2 == 0,"date","2026-08-03","time","2026-08-03T21:00:00")).toList();
var first=new ExportBatchResponse(1,"sha256:test","snapshot-9",columns,firstRows,"10",true,101L);
var second=new ExportBatchResponse(1,"sha256:test","snapshot-9",null,List.of(Map.of("text","\n=SUM(1,2)","integer",13,"decimal",4.5,"flag",false,"date","2026-08-04","time","2026-08-04T21:00:00")),null,false,101L);
when(provider.batch(anyString(),eq("CUSTOMER_LIST"),any(),any())).thenReturn(ApiResponse.success(first),ApiResponse.success(second));
@@ -58,7 +58,7 @@ class GenericXlsxExportRunnerTest {
assertEquals(101,result.rows());
try(var workbook=new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())){
var row=workbook.getSheetAt(0).getRow(1);assertEquals("'=SUM(1,2)",row.getCell(0).getStringCellValue());
assertEquals(CellType.NUMERIC,row.getCell(1).getCellType());assertEquals(CellType.NUMERIC,row.getCell(2).getCellType());assertEquals(CellType.BOOLEAN,row.getCell(3).getCellType());assertEquals(CellType.NUMERIC,row.getCell(4).getCellType());assertEquals(CellType.NUMERIC,row.getCell(5).getCellType());
assertEquals(CellType.NUMERIC,row.getCell(1).getCellType());assertEquals(CellType.NUMERIC,row.getCell(2).getCellType());assertEquals(CellType.STRING,row.getCell(3).getCellType());assertEquals("是",row.getCell(3).getStringCellValue());assertEquals("否",workbook.getSheetAt(0).getRow(2).getCell(3).getStringCellValue());assertEquals(CellType.NUMERIC,row.getCell(4).getCellType());assertEquals(CellType.NUMERIC,row.getCell(5).getCellType());
assertEquals("'\n=SUM(1,2)",workbook.getSheetAt(0).getRow(101).getCell(0).getStringCellValue());
assertTrue(workbook.getNumCellStyles()<10,"日期样式必须复用,不能按单元格创建");
}
@@ -0,0 +1,107 @@
package com.baiyue.finance.service;
import java.util.Locale;
import java.util.Map;
/** 导出业务枚举沿用财务页面中文口径,不改动筛选值、币种及业务编号。 */
final class FinanceExportLabels {
/** 中文导出映射仅提供静态转换。 */
private FinanceExportLabels() { }
private static final Map<String, String> STATUSES = Map.ofEntries(
Map.entry("ACTIVE", "正常"),
Map.entry("DISABLED", "停用"),
Map.entry("INACTIVE", "停用"),
Map.entry("DRAFT", "草稿"),
Map.entry("PENDING", "待审核"),
Map.entry("PENDING_REVIEW", "待审核"),
Map.entry("APPROVED", "已审核"),
Map.entry("ISSUED", "已出账"),
Map.entry("REJECTED", "已驳回"),
Map.entry("UNPAID", "未支付"),
Map.entry("PARTIALLY_PAID", "部分结清"),
Map.entry("PAID", "已结清"),
Map.entry("VOIDED", "已作废"),
Map.entry("AVAILABLE", "待结算"),
Map.entry("LOCKED", "结算锁定"),
Map.entry("BILLED", "已入账"),
Map.entry("SETTLED", "已结算"),
Map.entry("RECORDED", "已登记"),
Map.entry("CONFIRMED", "已确认"),
Map.entry("PENDING_CONFIRMATION", "待客户确认"),
Map.entry("NOT_REQUIRED", "无需确认"));
private static final Map<String, String> SOURCES = Map.ofEntries(
Map.entry("INBOUND_FEE", "入库费用"),
Map.entry("ORDER_FEE", "订单费用"),
Map.entry("CONTAINER_FEE", "装柜费用"),
Map.entry("CONTAINER_COST", "整柜成本"),
Map.entry("MANUAL_COST", "人工成本"),
Map.entry("STORAGE_FEE", "仓储费用"),
Map.entry("OVERSEAS_INBOUND_FEE", "海外入仓费用"),
Map.entry("OVERSEAS_PROCUREMENT_FEE", "海外采购费用"),
Map.entry("OVERSEAS_TRUCK_FEE", "本土卡车费用"),
Map.entry("OVERSEAS_LOOSEBOARD_FEE", "本土散板费用"),
Map.entry("OVERSEAS_WAREHOUSE_FEE", "海外仓服务费"),
Map.entry("OVERSEAS_CONTAINER_FEE", "海外柜子费用"),
Map.entry("OVERSEAS_ORDER_FEE", "尾程订单费用"),
Map.entry("OVERSEAS_DELIVERY_FEE", "尾程派送费用"),
Map.entry("OVERSEAS_RELABEL_FEE", "换标费用"));
private static final Map<String, String> BUSINESSES = Map.ofEntries(
Map.entry("RECHARGE", "充值"),
Map.entry("DEDUCTION", "扣款"),
Map.entry("CREDIT_LIMIT", "授信额度调整"),
Map.entry("FREEZE", "冻结"),
Map.entry("UNFREEZE", "解冻"),
Map.entry("CONSUMPTION", "消费扣款"),
Map.entry("REFUND", "退款"),
Map.entry("RECONCILIATION_CREATED", "生成对账单"),
Map.entry("SETTLEMENT_ISSUED", "结算单生效"),
Map.entry("RECEIPT", "收款"),
Map.entry("PAYMENT", "付款"),
Map.entry("SUPPLIER_DIRECT_PAID", "供应商线下直付"),
Map.entry("SUPPLIER_ON_ACCOUNT_PAYMENT", "供应商账期付款"),
Map.entry("ADJUSTMENT", "账务调整"),
Map.entry("VOID", "作废"),
Map.entry("BILL", "对账单"),
Map.entry("COST", "成本"),
Map.entry("SETTLEMENT", "结算"),
Map.entry("CREATE", "创建"),
Map.entry("APPROVE", "审核通过"),
Map.entry("REJECT", "审核驳回"),
Map.entry("UPDATE", "更新"),
Map.entry("FEE_SOURCE_EDIT", "编辑费用"),
Map.entry("FEE_SOURCE_SUBMIT", "提交审核"),
Map.entry("FEE_SOURCE_REVIEW_APPROVE", "审核通过"),
Map.entry("FEE_SOURCE_REVIEW_REJECT", "审核驳回"),
Map.entry("FEE_SOURCE_ADJUST", "调整费用"),
Map.entry("FEE_SOURCE_VOID", "作废费用"),
Map.entry("FEE_SOURCE_PUBLISHED", "费用同步"),
Map.entry("FEE_SOURCE_VERSION_ADJUST", "费用调整同步"),
Map.entry("RECONCILIATION_CREATE", "生成对账单"),
Map.entry("RECONCILIATION_ADJUST", "调整对账单"),
Map.entry("BILL_CREATE", "创建账单"),
Map.entry("BILL_ISSUE", "出账"),
Map.entry("BILL_ADJUST", "调整账单"),
Map.entry("BILL_VOID", "作废账单"),
Map.entry("SETTLEMENT_AR_CREATE", "生成应收结算单"),
Map.entry("SETTLEMENT_AP_CREATE", "生成应付结算单"),
Map.entry("SETTLEMENT_PAYMENT_CREATE", "登记收付款"),
Map.entry("RECEIPT_CREATE", "登记收款"),
Map.entry("COST_CREATE", "登记成本"),
Map.entry("REOPEN", "重开"));
/** 转换账户、审核及结算状态。 */
static String status(String value) { return label(value, STATUSES, "未知状态"); }
/** 转换费用来源类型。 */
static String source(String value) { return label(value, SOURCES, "未知来源类型"); }
/** 转换账务及资金流水业务类型。 */
static String business(String value) { return label(value, BUSINESSES, "未知业务类型"); }
/** 转换应收应付方向。 */
static String direction(String value) { return label(value, Map.of("RECEIVABLE", "应收", "PAYABLE", "应付"), "未知方向"); }
/** 空值保持空白,已是中文的历史快照保持原文;新增英文枚举不直接泄露到业务报表。 */
private static String label(String value, Map<String, String> labels, String fallback) {
if (value == null || value.isBlank()) return value;
String code = value.trim().toUpperCase(Locale.ROOT);
return labels.getOrDefault(code, value.matches(".*[一-鿿].*") ? value : fallback);
}
}
@@ -339,35 +339,39 @@ public class FinanceExportProviderService {
} catch (GeneralSecurityException exception) { throw new IllegalStateException("HmacSHA256 is unavailable", exception); }
}
/** 导出账户摘要,状态使用中文展示。 */
private Map<String, Object> accountRow(AccountSummary value) {
return row("customerCode", value.customerCode(), "customerName", value.customerName(), "contactName", value.contactName(),
"contactPhone", value.contactPhone(), "outstandingReceivable", value.outstandingReceivable(), "pendingBilling", value.pendingBilling(),
"receivedInPeriod", value.receivedInPeriod(), "totalCost", value.totalCost(), "paidCost", value.paidCost(),
"outstandingPayable", value.outstandingPayable(), "status", value.status(), "lastChangedAt", value.lastChangedAt());
"outstandingPayable", value.outstandingPayable(), "status", FinanceExportLabels.status(value.status()), "lastChangedAt", value.lastChangedAt());
}
/** 导出账户流水,转换业务类型并保留金额和编号。 */
private Map<String, Object> ledgerRow(LedgerEntry value) {
return row("ledgerId", String.valueOf(value.ledgerId()), "occurredAt", value.occurredAt(), "businessType", value.businessType(),
return row("ledgerId", String.valueOf(value.ledgerId()), "occurredAt", value.occurredAt(), "businessType", FinanceExportLabels.business(value.businessType()),
"documentNo", value.documentNo(), "receivableChange", value.receivableChange(), "payableChange", value.payableChange(),
"outstandingReceivableAfter", value.outstandingReceivableAfter(), "operatorName", value.operatorName(), "remark", value.remark());
}
/** 费用方向、来源类型及审核和结算状态统一展示中文。 */
private Map<String, Object> feeRow(FeeSourceSummary source) {
return row("customerName", source.customerName(), "direction", source.direction(), "sourceType", source.sourceType(),
return row("customerName", source.customerName(), "direction", FinanceExportLabels.direction(source.direction()), "sourceType", FinanceExportLabels.source(source.sourceType()),
"sourceFeeId", String.valueOf(source.sourceFeeId()), "sourceVersion", source.sourceVersion(), "inboundCode", source.inboundCode(),
"containerNo", source.containerNo(), "businessDate", source.businessDate(), "feeItemName", source.feeItemName(),
"quantity", source.quantity(), "unitPrice", source.unitPrice(), "amount", source.amount(),
"reviewStatus", source.reviewStatus(), "sourceStatus", source.sourceStatus(), "statementGenerated", source.statementGenerated());
"reviewStatus", FinanceExportLabels.status(source.reviewStatus()), "sourceStatus", FinanceExportLabels.status(source.sourceStatus()), "statementGenerated", source.statementGenerated());
}
/** 对账单导出沿用页面状态口径,是否结清仍传递布尔值。 */
private Map<String, Object> statementRow(ReconciliationDetail value) {
return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(),
"periodEnd", value.periodEnd(), "creditDays", value.creditDays(), "currency", value.currency(), "totalReceivable", value.totalReceivable(),
"receivedAmount", value.receivedAmount(), "outstandingAmount", value.outstandingAmount(), "settled", value.settled(), "status", value.status(),
"receivedAmount", value.receivedAmount(), "outstandingAmount", value.outstandingAmount(), "settled", value.settled(), "status", FinanceExportLabels.status(value.status()),
"remark", value.remark(), "createdByName", value.createdByName(), "createdAt", value.createdAt(), "updatedByName", value.updatedByName(), "updatedAt", value.updatedAt());
}
/** 结算单列表导出使用中文状态,便于财务人员直接阅读。 */
private Map<String, Object> settlementRow(SettlementSummary value) {
return row("settlementNo", value.settlementNo(), "customerName", value.customerName(), "supplierName", value.supplierName(),
"currency", value.currency(), "totalAmount", value.totalAmount(), "paidAmount", value.paidAmount(), "outstandingAmount", value.outstandingAmount(),
"businessPeriod", value.businessPeriod(), "generatedDate", value.generatedDate(), "dueDate", value.dueDate(), "status", settlementStatusLabel(value.status()));
"businessPeriod", value.businessPeriod(), "generatedDate", value.generatedDate(), "dueDate", value.dueDate(), "status", FinanceExportLabels.status(value.status()));
}
/** 明细导出与列表保持相同的中文结算状态。 */
private Map<String, Object> settlementDetailRow(SettlementSummary summary, SettlementLine line) {
@@ -375,18 +379,7 @@ public class FinanceExportProviderService {
"feeItemName", line == null ? null : line.feeItemName(), "inboundCode", line == null ? null : line.inboundCode(),
"containerNo", line == null ? null : line.containerNo(), "businessDate", line == null ? null : line.businessDate(),
"lineAmount", line == null ? null : line.amount(), "totalAmount", summary.totalAmount(), "paidAmount", summary.paidAmount(),
"outstandingAmount", summary.outstandingAmount(), "status", settlementStatusLabel(summary.status()));
}
/** 转换正式结算状态,未知值保留原值以便排查。 */
private String settlementStatusLabel(String status) {
if (status == null) return null;
return switch (status) {
case "ISSUED" -> "已出账";
case "PARTIALLY_PAID" -> "部分结清";
case "PAID" -> "已结清";
case "VOIDED" -> "已作废";
default -> status;
};
"outstandingAmount", summary.outstandingAmount(), "status", FinanceExportLabels.status(summary.status()));
}
private Map<String, Object> receivableStatisticsRow(YearMonth month, ReceivableStatisticsRow value) {
return row("statisticsMonth", month.toString(), "customerName", value.customerName(),
@@ -0,0 +1,34 @@
package com.baiyue.finance.service;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceExportLabelsTest {
@Test void translatesStatusSourceAndLedgerFamiliesWithoutLeakingCodes() {
assertThat(FinanceExportLabels.status("PARTIALLY_PAID")).isEqualTo("部分结清");
assertThat(FinanceExportLabels.status("SETTLED")).isEqualTo("已结算");
for (String code : List.of("ACTIVE", "DISABLED", "DRAFT", "PENDING", "PENDING_REVIEW", "APPROVED", "REJECTED", "ISSUED", "PARTIALLY_PAID", "PAID", "VOIDED", "AVAILABLE", "LOCKED", "BILLED")) {
assertThat(FinanceExportLabels.status(code)).matches(".*[一-鿿].*").doesNotContain("未知");
}
for (String code : List.of("INBOUND_FEE", "ORDER_FEE", "CONTAINER_FEE", "CONTAINER_COST", "MANUAL_COST", "STORAGE_FEE", "OVERSEAS_INBOUND_FEE", "OVERSEAS_PROCUREMENT_FEE", "OVERSEAS_TRUCK_FEE", "OVERSEAS_LOOSEBOARD_FEE", "OVERSEAS_WAREHOUSE_FEE", "OVERSEAS_CONTAINER_FEE", "OVERSEAS_ORDER_FEE", "OVERSEAS_DELIVERY_FEE", "OVERSEAS_RELABEL_FEE")) {
assertThat(FinanceExportLabels.source(code)).matches(".*[一-鿿].*").doesNotContain("未知");
}
for (String code : List.of("FEE_SOURCE_EDIT", "FEE_SOURCE_SUBMIT", "FEE_SOURCE_REVIEW_APPROVE", "FEE_SOURCE_REVIEW_REJECT", "FEE_SOURCE_ADJUST", "FEE_SOURCE_VOID", "RECONCILIATION_CREATED", "SETTLEMENT_ISSUED", "RECEIPT", "PAYMENT", "SUPPLIER_DIRECT_PAID", "SUPPLIER_ON_ACCOUNT_PAYMENT", "ADJUSTMENT", "VOID")) {
assertThat(FinanceExportLabels.business(code)).matches(".*[一-鿿].*").doesNotContain("未知");
}
assertThat(FinanceExportLabels.direction("RECEIVABLE")).isEqualTo("应收");
for (String code : List.of("RECHARGE", "DEDUCTION", "CREDIT_LIMIT", "FREEZE", "UNFREEZE", "CONSUMPTION", "REFUND")) {
assertThat(FinanceExportLabels.business(code)).matches(".*[一-鿿].*").doesNotContain("未知");
}
assertThat(FinanceExportLabels.direction("PAYABLE")).isEqualTo("应付");
}
@Test void handlesEmptyChineseSnapshotsAndUnknownValues() {
assertThat(FinanceExportLabels.status(null)).isNull();
assertThat(FinanceExportLabels.status("")).isEmpty();
assertThat(FinanceExportLabels.status("已结清")).isEqualTo("已结清");
assertThat(FinanceExportLabels.status("NEW_INTERNAL_STATUS")).isEqualTo("未知状态");
assertThat(FinanceExportLabels.status(" partially_paid ")).isEqualTo("部分结清");
}
}
@@ -45,6 +45,19 @@ class FinanceExportProviderServiceTest {
service.initializeSigningKey();
}
@Test
void exportsAccountAndLedgerEnumsAsChineseWithoutChangingFiltersOrReferences() throws Exception {
var mapper = new ObjectMapper().findAndRegisterModules();
var account = mapper.convertValue(Map.of("customerId", 10001L, "customerCode", "C-10001", "status", "ACTIVE"), com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary.class);
var ledger = mapper.convertValue(Map.of("ledgerId", 1L, "businessType", "SUPPLIER_ON_ACCOUNT_PAYMENT", "documentNo", "AP-1"), com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry.class);
when(accountService.list(1, 100, null, "ACTIVE", 10001L)).thenReturn(PageEnvelope.success(List.of(account), 1, 100, 1));
when(operationService.listDerivedCustomerAccountLedger(10001L, 1, 100, "SUPPLIER_ON_ACCOUNT_PAYMENT", null, null)).thenReturn(PageEnvelope.success(List.of(ledger), 1, 100, 1));
var accounts = service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, request(Map.of("customerId", "10001", "status", "ACTIVE"), null, null));
var ledgers = service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LEDGER, request(Map.of("customerId", "10001", "businessType", "SUPPLIER_ON_ACCOUNT_PAYMENT"), null, null));
assertThat(accounts.rows().get(0)).containsEntry("status", "正常").containsEntry("customerCode", "C-10001");
assertThat(ledgers.rows().get(0)).containsEntry("businessType", "供应商账期付款").containsEntry("documentNo", "AP-1");
}
@Test
/** 应付结算列表和详情导出均使用中文状态,包括无明细的单据。 */
void exportsSettlementStatusesInChinese() {
@@ -98,7 +111,7 @@ class FinanceExportProviderServiceTest {
assertThat(first.columns()).isNotEmpty();
assertThat(first.rows()).hasSize(1);
assertThat(first.rows().get(0)).containsEntry("statementNo", "REC-501").containsEntry("customerName", "客户A");
assertThat(first.rows().get(0)).containsEntry("statementNo", "REC-501").containsEntry("customerName", "客户A").containsEntry("status", "已出账");
assertThat(first.totalRows()).isEqualTo(1L);
assertThat(continued.columns()).isEmpty();
assertThat(continued.rows()).isEmpty();
@@ -119,7 +132,7 @@ class FinanceExportProviderServiceTest {
var result = service.exportBatch(FinanceExportProviderService.FEE_SOURCE_LIST,
request(Map.of("direction", "PAYABLE", "status", "PAID"), null, null));
assertThat(result.rows()).singleElement().satisfies(row -> {
assertThat(row).containsEntry("sourceStatus", "PAID").containsEntry("sourceFeeId", "9001");
assertThat(row).containsEntry("sourceStatus", "已结清").containsEntry("reviewStatus", "已审核").containsEntry("direction", "应付").containsEntry("sourceType", "装柜费用").containsEntry("sourceFeeId", "9001");
assertThat(row.get("amount")).isEqualTo(new BigDecimal("300"));
});
org.mockito.Mockito.verifyNoInteractions(queryService);
+1 -1
View File
@@ -32,7 +32,7 @@
| 称重确认、整票库位块、部分拣货结算 | `service/MeasurementLocationOccupationService.java`、`service/MeasurementSubmissionService.java`;查“首张称重图片”“删除称重图片”“PDA 部分拣货” |
| 审核与初始计价 | `service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`;查“测量审核通过” |
| 客户四档箱规与默认算法 | `service/CustomerDimensionConfigService.java`、`service/CustomerDimensionWeightingService.java`;查“客户箱规配置” |
| FBA 计价、客户专属价格簿、柜级阶梯运费 | `service/FbaPricingService.java`、`service/PlatformPriceBookService.java`、`pricing/ContainerTierAmountAllocator.java`;查“客户专属价格簿、柜级阶梯运费与调价边界” |
| FBA 计价、私人地址人工报价、客户价格簿与柜级阶梯运费 | `service/FbaPricingService.java`、`service/PlatformPriceBookService.java`、`pricing/ContainerTierAmountAllocator.java`;查“私人地址”“客户专属价格簿、柜级阶梯运费与调价边界” |
| 费用项目、共享费用目录与价格簿查询内部切片 | `controller/PlatformFeeItemController.java`、`controller/InternalFeeCatalogController.java`、`pricebookquery/application/PriceBookQueryService.java`;共享目录契约 `src/main/resources/openapi/fee-catalog-internal-v1.yaml`,价格簿查“价格簿查询内部切片” |
| 入库费用、客户确认、直接已付 | `service/InboundFeeDetailService.java`、`service/MerchantInboundBillingService.java`、`service/FeePaymentSnapshotValidator.java`;查“客户入库费用”“DIRECT_PAID” |
| 柜次费用、跨客户分摊与财务快照 | `service/ContainerFeeDetailService.java`、`controller/InternalFinanceFeeSourceController.java`、`loading/service/FinanceCostSnapshotService.java`;查“柜次费用明细与跨客户分摊组” |
+1 -1
View File
@@ -47,7 +47,7 @@
| 货柜与全局智能排柜 | `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 的当前唯一版本 |
| PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 |
| 装柜完成直连事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`loading/mapper/CabinetLoadingEvidenceMapper.java`、`client/LastlegEventClient.java`;当前发布 V3,从 `fl_inbound_order.destination_type` 冻结 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数,并冻结物理柜五类有效装柜照片引用;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 装柜完成直连事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`loading/mapper/CabinetLoadingEvidenceMapper.java`、`client/LastlegEventClient.java`;当前发布 V3,从 `fl_inbound_order.destination_type` 冻结 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),国内 PRIVATE 按 OVERSEAS 交接并保留私人地址及箱码快照,同一入仓单的原始目的类型必须唯一;OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数,并冻结物理柜五类有效装柜照片引用;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` |
| 海外派送完成回写 | 同一内部事件入口、消费者 `service/OverseasDeliveryCompletedConsumer.java`;按来源入仓单、订单、入仓码和 FBA 仓校验后推进当前入仓单 `LOADED→DELIVERED`,同一 Order 全部有效入仓单完成后才经 `OrderFulfillmentCompletionClient` 推进 Order `COMPLETED` |
@@ -87,11 +87,12 @@ public class PlatformFbaPricingController {
return ApiResponse.success(service.confirm(scope, user.userId(), inboundId, idempotencyKey));
}
/** 按费用发送权限发布已确认费用,审核门禁由服务端校验且不推进履约状态。 */
@PostMapping("/send")
@PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
+ " 'firstleg:inbound:send-fee')")
@BusinessAudit(event = "FBA_FEE_SEND", resourceType = "INBOUND", resourceId = "#inboundId",
description = "发送FBA费用快照并进入待托盘状态")
description = "发送FBA费用快照")
public ApiResponse<FeeSnapshotResponse> send(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey) {
@@ -254,6 +254,9 @@ public class ContainerCustomsService {
throw conflict("当前柜尚未完成首次发送报关行资料");
}
CompletionRow completion = mapper.lockCompletion(documentId);
if (completion != null && "CONFIRMED".equals(completion.customerConfirmStatus())) {
throw conflict("客户已确认预录单,不可再次上传");
}
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
long completionId;
int version;
@@ -14,7 +14,7 @@ public interface CabinetLoadingEvidenceMapper extends BaseMapper<CabinetLoadingE
@Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} ORDER BY evidence_type,id")
List<CabinetLoadingEvidenceEntity> byContainer(@Param("containerBookingId") long containerBookingId);
@Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} AND evidence_type=#{type} AND active_flag=1 LIMIT 1 FOR UPDATE")
@Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} AND evidence_type=#{type} AND active_flag=1 ORDER BY id DESC LIMIT 1 FOR UPDATE")
CabinetLoadingEvidenceEntity activeForUpdate(@Param("containerBookingId") long containerBookingId,
@Param("type") String type);
@@ -74,8 +74,6 @@ public class CabinetLoadingEvidenceService {
if (current != null && !Objects.equals(current.getVersion(), expected)) throw conflict("装柜证据版本已变化,请刷新后重试");
if (current != null && Objects.equals(current.getFileId(), request.fileId())
&& Objects.equals(current.getSealNo(), sealNo)) return view(scope);
if (current != null && mapper.voidActive(current.getId(), scope.containerBookingId(), current.getVersion(),
"REPLACED", operatorId, operatorName) != 1) throw conflict("装柜证据版本已变化,请刷新后重试");
CabinetLoadingEvidenceEntity value = new CabinetLoadingEvidenceEntity();
value.setContainerBookingId(scope.containerBookingId()); value.setSourceTaskId(taskId);
value.setEvidenceType(type); value.setFileId(request.fileId()); value.setFileName(file.fileName());
@@ -84,8 +82,7 @@ public class CabinetLoadingEvidenceService {
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName);
try { mapper.insert(value); } catch (DuplicateKeyException exception) {
// A replacement may already have voided the old row in this transaction.
// Roll it back; a subsequent same-key request can read the committed winner.
// The task-scoped operation key protects concurrent duplicate uploads.
throw conflict("装柜证据发生并发变更,请刷新后重试");
}
return view(scope);
@@ -100,8 +100,8 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
WHERE departure.container_booking_id=b.id
AND departure.confirmed_at IS NOT NULL AND departure.reversed_at IS NULL
AND departure.status='WAITING_FOR_LOADING')
AND ((#{query.scope}='ACTIVE' AND b.actual_empty_reported_at IS NULL)
OR (#{query.scope}='HISTORY' AND b.actual_empty_reported_at IS NOT NULL))))
AND ((#{query.scope}='ACTIVE' AND NOT EXISTS (SELECT 1 FROM fl_terminal_drayage returned WHERE returned.container_booking_id=b.id AND returned.actual_empty_return_at IS NOT NULL UNION ALL SELECT 1 FROM fl_carrier_schedule_node_fact returned_node WHERE returned_node.container_booking_id=b.id AND returned_node.standard_node='EMPTY_RETURNED' AND returned_node.actual_at IS NOT NULL))
OR (#{query.scope}='HISTORY' AND EXISTS (SELECT 1 FROM fl_terminal_drayage returned WHERE returned.container_booking_id=b.id AND returned.actual_empty_return_at IS NOT NULL UNION ALL SELECT 1 FROM fl_carrier_schedule_node_fact returned_node WHERE returned_node.container_booking_id=b.id AND returned_node.standard_node='EMPTY_RETURNED' AND returned_node.actual_at IS NOT NULL)))))
AND (#{query.keyword} IS NULL OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.bill_of_lading_no LIKE CONCAT('%', #{query.keyword}, '%')
@@ -379,6 +379,26 @@ public interface DropshipPalletizationMapper {
ORDER BY inbound_detail_id
""") List<PalletLine> sealedPalletLines(@Param("palletId") long palletId);
/** 未装托盘标签按物理箱身份转移,保留原箱码不重新编号。 */
@Update("""
<script>UPDATE fl_inbound_carton_label SET inbound_id=#{child},order_id=#{order},inbound_code=#{code},
total_carton_count=#{packages},detail_package_count=LEAST(detail_package_count,#{packages})
WHERE tenant_id=#{tenant} AND inbound_id=#{source} AND batch_status='ACTIVE'
AND id IN (SELECT c.carton_label_id FROM fl_dropship_pallet_carton c
WHERE c.tenant_id=#{tenant} AND c.inbound_id=#{source} AND c.pallet_id IS NOT NULL
AND c.pallet_id NOT IN <foreach collection="picked" item="id" open="(" separator="," close=")">#{id}</foreach>)
</script>""")
int transferRemainderLabels(@Param("tenant") long tenant,@Param("source") long source,@Param("child") long child,
@Param("order") long order,@Param("code") String code,@Param("packages") int packages,@Param("picked") List<Long> picked);
/** 实箱随余量单转移,已装托与跨客户托内其他成员保持原归属。 */
@Update("""
<script>UPDATE fl_dropship_pallet_carton SET inbound_id=#{child}
WHERE tenant_id=#{tenant} AND inbound_id=#{source} AND pallet_id IS NOT NULL
AND pallet_id NOT IN <foreach collection="picked" item="id" open="(" separator="," close=")">#{id}</foreach>
</script>""")
int transferRemainderCartons(@Param("tenant") long tenant,@Param("source") long source,
@Param("child") long child,@Param("picked") List<Long> picked);
@Getter @Setter class TaskRow { private Long id,tenantId,inboundId,warehouseId,assigneeId; private String inboundCode,customerName,warehouseName,taskStatus,assigneeName,processStatus,fulfillmentType; private Integer totalCartons,countedCartons,allocatedCartons,discrepancyCartons,expectedPalletCount,version; }
@Getter @Setter class PalletRow { private Long id,taskId,tenantId,inboundId,warehouseId; private String palletNo,palletStatus,sealOperationId; private Integer version; private LocalDateTime completedAt; }
@Getter @Setter class PlanPalletMemberRow { private Long palletId,accountId,inboundId,orderId; private String orderNo,inboundCode,customerName,orderStatusSnapshot; private Integer availablePackageCount; private LocalDateTime expectedArrivalAt; }
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 分托装柜余量的实箱归属迁移。 */
@Repository
public class DropshipSplitRepository {
private final DropshipPalletizationMapper mapper;
/** 复用一件代发实箱账本,不另建库存。 */
public DropshipSplitRepository(DropshipPalletizationMapper mapper) { this.mapper = mapper; }
/** 排除本柜已拣托盘,逐箱迁移到余量入仓单;数量不一致回滚本地结算。 */
public void transferRemainder(long tenant, long source, long child, long order, String code,
int packages, List<Long> pickedPallets) {
if (pickedPallets.isEmpty()) throw new BizException(ResultCode.CONFLICT, "缺少已装整托事实");
if (mapper.transferRemainderLabels(tenant, source, child, order, code, packages, pickedPallets) != packages
|| mapper.transferRemainderCartons(tenant, source, child, pickedPallets) != packages)
throw new BizException(ResultCode.CONFLICT, "未装托盘箱数与余量不一致,请刷新后重试");
}
}
@@ -331,6 +331,7 @@ public class FbaPricingRepository {
return inbounds.update(null, update);
}
/** 保存试算及计费量,保留测量、审核与仓库作业的主状态。 */
public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode,
java.math.BigDecimal selectedWeightKg,
java.math.BigDecimal selectedVolumeCbm,
@@ -341,10 +342,6 @@ public class FbaPricingRepository {
.eq(InboundPricingEntity::getProcessStatus, processStatus)
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED", "SENT"))
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
if (!List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")
.contains(processStatus)) {
update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING");
}
applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeMode, chargeable);
return inbounds.update(null, update);
}
@@ -383,12 +380,14 @@ 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("WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT",
"LOADING", "LOADED", "DELIVERED"))
.eq(InboundPricingEntity::getMeasureReviewStatus, "APPROVED")
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
}
@@ -436,15 +435,14 @@ public class FbaPricingRepository {
.set(InboundPricingEntity::getFeeStatus, "VOIDED"));
}
/** 发送仅更新费用状态,并原子校验测量已审核及履约状态未变化。 */
public int markInboundFeeSent(long tenantId, long inboundId, String processStatus) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
.eq(InboundPricingEntity::getProcessStatus, processStatus)
.eq(InboundPricingEntity::getMeasureReviewStatus, "APPROVED")
.eq(InboundPricingEntity::getFeeStatus, "CONFIRMED")
.set(InboundPricingEntity::getFeeStatus, "SENT");
if ("FEE_CONFIRMING".equals(processStatus)) {
update.set(InboundPricingEntity::getProcessStatus, "WAIT_PALLET");
}
return inbounds.update(null, update);
}
@@ -196,9 +196,8 @@ public class FbaPricingService {
}
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
complete(token, fee.getId(), now);
String nextStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus())
? inbound.getProcessStatus() : "FEE_CONFIRMING";
return response(inbound, fee, rate.getUnitPrice(), nextStatus);
// 费用是独立状态,提前试算不得覆盖待测量或待审核等履约进度。
return response(inbound, fee, rate.getUnitPrice(), inbound.getProcessStatus());
}
/** 价格未命中时按人工单价生成正式费用,计费量与只读预览保持一致。 */
@@ -229,9 +228,10 @@ public class FbaPricingService {
if (current != null && !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) {
throw conflict("当前费用状态不允许重新定价");
}
LocalDateTime now = LocalDateTime.now(clock);
// 与DATETIME(3)保存精度一致,首次响应和原键重放保留同一时间事实。
LocalDateTime now = LocalDateTime.now(clock).truncatedTo(java.time.temporal.ChronoUnit.MILLIS);
PricingPolicy policy = pricingPolicy(inbound, now);
// 私人地址使用人工报价;FBA仍须先证明价格表未命中,避免绕开有效报价。
// 私人地址使用人工报价。FBA仍须先证明价格表未命中,避免绕开有效报价。
if ("FBA".equals(inbound.getDestinationType())) {
try {
priceBookEstimator.estimate(tenantId, new EstimateRequest(
@@ -289,9 +289,7 @@ public class FbaPricingService {
weight, volume, quantity) != 1) throw conflict("入库单状态已变化,费用未保存");
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
complete(token, fee.getId(), now);
String nextStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus())
? inbound.getProcessStatus() : "FEE_CONFIRMING";
return response(inbound, fee, request.unitPrice(), nextStatus);
return response(inbound, fee, request.unitPrice(), inbound.getProcessStatus());
}
/** 预览和正式计价共用价格匹配与柜级阶梯分摊,避免前端复制计价公式。 */
@@ -576,10 +574,14 @@ public class FbaPricingService {
public record ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus status,
int recalculatedCount) { }
/** 测量审核通过后才允许确认费用,避免提前冻结费用阻断审核后的重新计价。 */
@Transactional
public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId,
String idempotencyKey) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) {
throw conflict("测量数据尚未审核通过,请先完成测量审核后再确认费用");
}
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
throw conflict("当前入库单状态不允许确认费用");
}
@@ -598,10 +600,14 @@ public class FbaPricingService {
return response(tenantId, current.getId(), null);
}
/** 仅发布已通过测量审核的费用,发送不改变测量与仓库作业进度。 */
@Transactional
public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId,
String idempotencyKey) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) {
throw conflict("测量数据尚未审核通过,请先完成测量审核后再发送客户");
}
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
throw conflict("当前入库单状态不允许发送费用");
}
@@ -610,8 +616,7 @@ public class FbaPricingService {
String hash = hash("SEND", inboundId, current.getId().toString());
InboundPricingIdempotencyEntity token = beginIdempotency(
tenantId, operatorId, inboundId, "FEE_SEND", idempotencyKey, hash);
String nextProcessStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus())
? inbound.getProcessStatus() : "WAIT_PALLET";
String nextProcessStatus = inbound.getProcessStatus();
if ("COMPLETED".equals(token.getStatus())) {
feeDetails.publishFreight(tenantId, inboundId, token.getResultResourceId(),
operatorId, null, LocalDateTime.now(clock));
@@ -811,11 +816,6 @@ public class FbaPricingService {
return inbound;
}
private static boolean preserveProcessStatusDuringPricing(String processStatus) {
return List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")
.contains(processStatus);
}
private static boolean feeRevisionStatusSupported(String processStatus) {
return List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
"WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING",
@@ -169,24 +169,23 @@ public class LoadingCompletedOutboxService {
traceId, eventKey, null, payloadNode, hash).requireValid();
}
/** 国内同类照片可追加,交接按每类最新有效照片冻结一张对照图。 */
private List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> loadingEvidence(Long containerBookingId) {
if (containerBookingId == null || containerBookingId <= 0) {
throw new BizException(ResultCode.CONFLICT, "装柜计划缺少柜次,不能冻结装柜照片");
}
List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> result = cabinetEvidenceMapper
.byContainer(containerBookingId).stream()
Map<String, FirstLegLoadingPlanCompletedV3.LoadingEvidence> latestByType = new java.util.TreeMap<>();
// Mapper 按类型、证据 ID 升序返回;同类后到的有效照片覆盖前一张,原始证据全部保留。
cabinetEvidenceMapper.byContainer(containerBookingId).stream()
.filter(value -> Integer.valueOf(1).equals(value.getActiveFlag()) && "ACTIVE".equals(value.getStatus()))
.map(value -> new FirstLegLoadingPlanCompletedV3.LoadingEvidence(
value.getEvidenceType(), value.getFileId(), value.getFileName()))
.sorted(Comparator.comparing(FirstLegLoadingPlanCompletedV3.LoadingEvidence::evidenceType))
.toList();
Set<String> types = result.stream().map(FirstLegLoadingPlanCompletedV3.LoadingEvidence::evidenceType)
.collect(java.util.stream.Collectors.toSet());
if (result.size() != 5 || !types.equals(Set.of(
.forEach(value -> latestByType.put(value.getEvidenceType(),
new FirstLegLoadingPlanCompletedV3.LoadingEvidence(
value.getEvidenceType(), value.getFileId(), value.getFileName())));
if (!latestByType.keySet().equals(Set.of(
"PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL"))) {
throw new BizException(ResultCode.CONFLICT, "国内装柜五类照片不完整,不能生成尾程交接事件");
}
return result;
return List.copyOf(latestByType.values());
}
private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows,
@@ -335,11 +334,13 @@ public class LoadingCompletedOutboxService {
Set<String> values = rows.stream().map(Row::getDestinationType)
.map(value -> value == null ? "" : value.trim().toUpperCase(java.util.Locale.ROOT))
.collect(java.util.stream.Collectors.toSet());
if (values.size() != 1 || (!values.contains("FBA") && !values.contains("OVERSEAS"))) {
if (values.size() != 1
|| (!values.contains("FBA") && !values.contains("OVERSEAS") && !values.contains("PRIVATE"))) {
throw new BizException(ResultCode.CONFLICT,
"装柜入仓单目的类型必须唯一且为 FBA 或 OVERSEAS,不能生成海外交接事件");
"装柜入仓单目的类型必须唯一且为 FBA、OVERSEAS 或 PRIVATE,不能生成海外交接事件");
}
return values.iterator().next();
String value = values.iterator().next();
return "PRIVATE".equals(value) ? "OVERSEAS" : value;
}
private String fulfillmentType(List<Row> rows) {
@@ -53,6 +53,8 @@ public class PdaPickSettlementService {
private InboundHoldGuard inboundHoldGuard;
@Resource
private DropshipPalletizationMapper dropshipPallets;
@Resource
private com.baiyue.firstleg.repository.DropshipSplitRepository dropshipSplits;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@@ -121,6 +123,7 @@ public class PdaPickSettlementService {
}
}
/** 按柜次实拣数量结算,一件代发保留未装整托供后续柜次使用。 */
private boolean settleInbound(long parentInboundId, int pickedPackageCount,
List<LoadingPickItemEntity> taskItems, long taskId,
long operatorId, String operatorName) {
@@ -184,9 +187,7 @@ public class PdaPickSettlementService {
&& pickedByDetail.values().stream().mapToInt(Integer::intValue).sum() != pickedPackageCount) {
throw conflict("PDA 已拣明细与入库单已拣箱数不一致");
}
if (dropshipWholePallet && childPackageCount != 0) {
throw conflict("一件代发现场托盘必须整票装柜,不能生成余量分片");
}
List<MeasurementSplitLine> measurementLines = dropshipWholePallet
? wholePalletMeasurementLines(measurements, pickedPackageCount, parentAvailable)
: measurementLines(measurements, pickedByDetail);
@@ -219,8 +220,17 @@ public class PdaPickSettlementService {
requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), parentInboundId, rootInboundId, remainderChildId,
orderSplit.remainderChild().orderId(), orderSplit.remainderChild().orderNo(),
remainderChildCode, childPackageCount, sequence, "REMAINDER", operatorId, operatorName), "余量子入库单");
splits.transferRemainingCartonLabels(parent.tenantId(), parentInboundId, remainderChildId,
orderSplit.remainderChild().orderId(), remainderChildCode, pickedPackageCount, childPackageCount);
if (dropshipWholePallet) {
var pickedPalletIds = taskItems.stream().filter(item -> "PICKED".equals(item.getItemStatus())
&& "SEALED_PALLET".equals(item.getUnitType())).map(LoadingPickItemEntity::getPalletId)
.filter(Objects::nonNull).distinct().toList();
// 托内箱按实际箱唛移动,不能按箱序号截取,否则会移错已装托盘。
dropshipSplits.transferRemainder(parent.tenantId(), parentInboundId, remainderChildId,
orderSplit.remainderChild().orderId(), remainderChildCode, childPackageCount, pickedPalletIds);
} else {
splits.transferRemainingCartonLabels(parent.tenantId(), parentInboundId, remainderChildId,
orderSplit.remainderChild().orderId(), remainderChildCode, pickedPackageCount, childPackageCount);
}
splits.normalizeLoadedCartonLabels(parent.tenantId(), parentInboundId, pickedPackageCount);
}
requireAffected(splits.insertBatchWithChildren(batchId, parent.tenantId(), rootInboundId, operationId, parentInboundId,
@@ -408,17 +418,24 @@ public class PdaPickSettlementService {
return result;
}
/** 一件代发逐箱封托已经冻结 SKU 归属;测量批次只承担整票体积重量,不再冒充 SKU 明细。 */
/** 整托箱数守恒,测量行按箱数分摊重量体积;真实SKU组成仍由封托箱账本冻结。 */
private static List<MeasurementSplitLine> wholePalletMeasurementLines(List<MeasurementItemRow> measurements,
int pickedPackageCount,
int parentAvailable) {
int measuredPackages = measurements.stream()
.mapToInt(item -> count(item.packageCount(), "测量明细箱数")).sum();
if (pickedPackageCount != parentAvailable || measuredPackages != parentAvailable) {
if (pickedPackageCount <= 0 || pickedPackageCount > parentAvailable || measuredPackages != parentAvailable) {
throw conflict("一件代发整托箱数与入库单、测量批次不守恒");
}
return measurements.stream().map(item -> new MeasurementSplitLine(item,
count(item.packageCount(), "测量明细箱数"), 0)).toList();
int left = pickedPackageCount;
List<MeasurementSplitLine> result = new ArrayList<>();
for (var item : measurements) {
int total = count(item.packageCount(), "测量明细箱数");
int picked = Math.min(total, left);
result.add(new MeasurementSplitLine(item, picked, total - picked));
left -= picked;
}
return List.copyOf(result);
}
private static Map<Long, Integer> measurementQuantities(List<MeasurementSplitLine> lines) {
@@ -72,7 +72,7 @@ public class SplitPreloadService {
@Transactional
public void ensurePendingForLoadingAllocation(long tenantId, long inboundId, long operatorId,
String operatorName) {
ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, false);
ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName);
}
/** Creates a provisional preload lifecycle for an order selected before it reaches WAIT_SPLIT. */
@@ -113,16 +113,17 @@ public class SplitPreloadService {
@Transactional
public void ensureForRunningLoadingAllocation(long tenantId, long inboundId, long operatorId,
String operatorName) {
ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, true);
ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName);
}
/** 复用既有预装计划;同一入仓单其他未分配整托可继续进入不同柜次。 */
private void ensureLoadingAllocationLifecycle(long tenantId, long inboundId, long operatorId,
String operatorName, boolean running) {
String operatorName) {
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
boolean waitingSplit = isWaitingSplit(inbound.processStatus());
boolean loading = "LOADING".equals(inbound.processStatus());
if (!waitingSplit && !(running && loading)) {
if (!waitingSplit && !loading) {
throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以建立智能装柜预装计划");
}
if (loading && plan == null) {
@@ -27,7 +27,9 @@ public class WorkflowTabService {
if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable("费用确认事实不属于本批入仓来源");
result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee);
}
var sources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
// 出库单由尾程审核建单触发,不能因国内开船节点尚未补录而跳过事实查询。
var sources=facts.stream().map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
var requiredSources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
Map<String,WorkflowFacts.Overseas> overseas=new HashMap<>();
if(!sources.isEmpty()) {
try {
@@ -40,8 +42,10 @@ public class WorkflowTabService {
if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable("尾程事实包含空记录或本批以外的客户/入仓来源");
overseas.merge(fact.tenantId()+":"+fact.inboundId(),fact,(a,b)->new WorkflowFacts.Overseas(a.tenantId(),a.inboundId(),a.outboundExists()||b.outboundExists(),a.pendingOutbound()||b.pendingOutbound(),a.delivering()||b.delivering(),a.unloaded()&&b.unloaded()));
}
if(overseas.size()!=sources.size()) throw unavailable("尾程事实缺少来源 expected="+sources.size()+" actual="+overseas.size()
+" missing="+sources.stream().filter(s->!overseas.containsKey(s.tenantId()+":"+s.inboundId())).limit(20).toList());
// 未开船来源可能尚未产生尾程订单;已开船来源仍须完整返回,避免依赖缺项被误当作零。
var missing=requiredSources.stream().filter(s->!overseas.containsKey(s.tenantId()+":"+s.inboundId())).toList();
if(!missing.isEmpty()) throw unavailable("尾程事实缺少来源 expected="+requiredSources.size()+" actual="+(requiredSources.size()-missing.size())
+" missing="+missing.stream().limit(20).toList());
} catch(BizException e) { throw e; } catch(Exception e) {
throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试").initCause(e);
}
@@ -61,6 +65,11 @@ public class WorkflowTabService {
Set<String> keys=new LinkedHashSet<>();
if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW");
boolean signed="DELIVERED".equals(process);
// 有效出库与派送事实独立于船期、清关资料;签收后不再保留待出库或派送任务。
if(!signed && o!=null) {
if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT");
if(o.delivering()) keys.add("DELIVERING");
}
// 人工补录或船司查询的实际开船节点才推进阶段,报关完成和提前上传资料不能代替开船。
boolean clearanceStage=f.departed();
if(!clearanceStage && !signed) {
@@ -75,8 +84,6 @@ public class WorkflowTabService {
if(!o.outboundExists() && !o.unloaded()) keys.add("WAIT_UNLOAD");
// 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。
if(!o.outboundExists()) keys.add("WAIT_DISPATCH");
if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT");
if(o.delivering()) keys.add("DELIVERING");
}
}
return keys;
@@ -0,0 +1,16 @@
-- Existing databases: allow multiple active photos per evidence type. No photo rows are changed.
USE baiyue_firstleg;
SET @evidence_ddl = IF(EXISTS(
SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_loading_cabinet_evidence' AND index_name='uk_cabinet_evidence_active'
), 'ALTER TABLE fl_loading_cabinet_evidence DROP INDEX uk_cabinet_evidence_active', 'SELECT 1');
PREPARE evidence_ddl FROM @evidence_ddl;
EXECUTE evidence_ddl;
DEALLOCATE PREPARE evidence_ddl;
SET @evidence_ddl = IF(EXISTS(
SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_loading_cabinet_evidence' AND index_name='idx_cabinet_evidence_active'
), 'SELECT 1', 'ALTER TABLE fl_loading_cabinet_evidence ADD INDEX idx_cabinet_evidence_active (container_booking_id,evidence_type,active_flag)');
PREPARE evidence_ddl FROM @evidence_ddl;
EXECUTE evidence_ddl;
DEALLOCATE PREPARE evidence_ddl;
@@ -11,7 +11,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence (
seal_no VARCHAR(64) NULL COMMENT '带锁码照对应锁码',
remark VARCHAR(1000) NULL COMMENT '证据备注',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID作废',
active_flag TINYINT UNSIGNED NULL DEFAULT 1 COMMENT '有效唯一标识;作废后置NULL',
active_flag TINYINT UNSIGNED NULL DEFAULT 1 COMMENT '有效标识;同类型允许多张,作废后置NULL',
operation_key VARCHAR(128) NOT NULL COMMENT '任务范围内幂等操作键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
void_reason VARCHAR(500) NULL COMMENT '作废原因',
@@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag),
KEY idx_cabinet_evidence_active (container_booking_id,evidence_type,active_flag),
UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key),
KEY idx_cabinet_evidence_container (container_booking_id,status),
KEY idx_cabinet_evidence_seal (seal_no,active_flag),
@@ -171,13 +171,13 @@ paths:
/api/platform/v1/inbounds/{inboundId}/fees/confirm:
post:
summary: 确认最新费用快照
description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:fba-pricing:confirm。确认费用只更新费用状态;主流程已到 WAIT_SPLIT 时保持不变。
description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:fba-pricing:confirm。测量审核通过后才可确认费用;未通过时拒绝且不冻结费用。确认只更新费用状态,不改变履约主状态。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/send:
post:
summary: 发送已确认费用快照
description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:inbound:send-fee。人工发布当前已确认版本;仅此动作将费用置为 SENT、生成 PUBLISHED 的 FBA_PRICING 只读派生明细并写入 Finance Outbox。入库单已到 WAIT_SPLIT 时不得回退或阻塞主流程。
description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:inbound:send-fee,无需额外的测量查看权限。服务端校验测量审核通过后才可发布当前已确认版本;仅此动作将费用置为 SENT、生成 PUBLISHED 的 FBA_PRICING 只读派生明细并写入 Finance Outbox,不改变履约主状态。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/latest:
@@ -1679,7 +1679,7 @@ components:
type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW]
nullable: true
description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,报关完成或实际开船后移出;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;生成出库单后进入 OUTBOUND_DOCUMENT,实际出库后进入 DELIVERING,派送完成进入 SIGNED。
description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,报关完成或实际开船后移出;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;尾程排车理货审核通过并生成有效出库单后进入 OUTBOUND_DOCUMENT,不以前置开船节点或清关资料为条件;取消或实际出库后移出,实际出库并发车后进入 DELIVERING,派送完成进入 SIGNED。
customerId: {type: integer, format: int64, nullable: true}
receivingWarehouseId: {type: integer, format: int64, nullable: true}
destinationWarehouseId: {type: integer, format: int64, nullable: true}
@@ -140,6 +140,7 @@ paths:
responses: {'200': {description: 仅platform:firstleg:pda:work且任务必须由当前用户领取}}
post:
summary: 通过Firstleg代理上传并登记当前柜次装柜证据
description: 同类型照片追加保留,不替换已有有效照片。expectedVersion取当前同类型最新有效照片版本,无照片时为0;重复幂等键不追加。
parameters:
- {$ref: '#/components/parameters/TaskId'}
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
@@ -304,6 +304,19 @@ class ContainerCustomsServiceTest {
verify(mapper,never()).insertBrokerSubmission(anyLong(),anyLong(),anyLong(),anyString(),anyInt(),anyString(),anyLong(),anyString());
}
@Test void receiptUploadRejectsAfterCustomerConfirmationBeforeBrokerExecution(){
var source=document(1L);var declaring=container(ContainerCustomsService.DECLARING,3);
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("confirmed"),anyString())).thenReturn(1);
when(mapper.document(1L)).thenReturn(source);when(mapper.lockContainerById(2L)).thenReturn(declaring);
when(mapper.lockDocument(1L)).thenReturn(source);when(mapper.brokerSubmission(2L)).thenReturn(brokerSubmission());
when(mapper.lockCompletion(1L)).thenReturn(new ContainerCustomsMapper.CompletionRow(21L,1L,99L,
"receipt.pdf","b".repeat(64),"application/pdf",1L,5L,"admin",java.time.LocalDateTime.now(),1,"CONFIRMED",null));
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","confirmed",1,
new MockMultipartFile("file","receipt.pdf","application/pdf",new byte[]{1})))
.isInstanceOf(BizException.class).hasMessageContaining("客户已确认预录单");
verifyNoInteractions(files);
}
@Test void receiptUploadRejectsWhenInitialBrokerSubmissionIsMissing(){
var source=document(1L);var container=container(ContainerCustomsService.DECLARING,3);
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("before-send"),anyString())).thenReturn(1);
@@ -58,7 +58,7 @@ class CabinetLoadingEvidenceTransactionTest {
}).collect(Collectors.joining(","));
jdbc.execute("CREATE TABLE fl_loading_cabinet_evidence(" + columns + ")");
jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence ADD CONSTRAINT uq_operation UNIQUE(source_task_id,operation_key)");
jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence ADD CONSTRAINT uq_seal UNIQUE(seal_no,active_flag)");
jdbc.execute("CREATE INDEX idx_active ON fl_loading_cabinet_evidence(container_booking_id,evidence_type,active_flag)");
String taskColumns = Arrays.stream(LoadingPickTaskEntity.class.getDeclaredFields())
.filter(field -> !Modifier.isStatic(field.getModifiers())).map(field ->
field.getName().replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase() + " "
@@ -98,7 +98,7 @@ class CabinetLoadingEvidenceTransactionTest {
}
@Test
void replacementInsertFailureRollsBackOldActiveRowAndSameRequestCanRetry() {
void appendInsertFailureRetainsOldActiveRowAndSameRequestCanRetry() {
nextFile = 301; upload("original");
var before = snapshot();
jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence ADD CONSTRAINT reject_new_file CHECK(file_id <> 302)");
@@ -108,11 +108,11 @@ class CabinetLoadingEvidenceTransactionTest {
jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence DROP CONSTRAINT reject_new_file");
upload("replacement");
assertThat(mapper.byContainer(99)).hasSize(2).filteredOn(row -> "ACTIVE".equals(row.getStatus()))
.singleElement().satisfies(row -> assertThat(row.getFileId()).isEqualTo(302L));
.extracting(CabinetLoadingEvidenceEntity::getFileId).containsExactly(301L, 302L);
}
@Test
void duplicateKeyAfterStaleReplayReadsMustRollBackPriorVoid() {
void duplicateKeyAfterStaleReplayReadsRetainsAllPhotos() {
nextFile = 301; upload("original");
nextFile = 302; upload("winner");
var before = snapshot();
@@ -167,7 +167,7 @@ class CabinetLoadingEvidenceTransactionTest {
boolean secondPassed = second.get(20, java.util.concurrent.TimeUnit.SECONDS);
assertThat(firstPassed || secondPassed).isTrue();
assertThat(mapper.byContainer(99)).hasSize(2).filteredOn(row -> "ACTIVE".equals(row.getStatus()))
.singleElement().satisfies(row -> assertThat(row.getOperationKey()).isEqualTo("concurrent"));
.extracting(CabinetLoadingEvidenceEntity::getOperationKey).containsExactly("original", "concurrent");
var committed = snapshot();
upload("concurrent");
assertThat(snapshot()).isEqualTo(committed);
@@ -28,6 +28,29 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("货柜数据库映射契约")
class ContainerBookingMapperContractTest {
/** 卸货不等于还柜;人工还柜及船司实际还柜都进入历史页签,两个集合互斥。 */
@Test void completedScopeUsesActualEmptyReturnInsteadOfUnloadingReport() throws Exception {
String page = String.join("\n", ContainerBookingMapper.class
.getDeclaredMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class,
com.baiyue.firstleg.dto.ContainerDtos.Query.class, String.class)
.getAnnotation(Select.class).value());
int start = page.indexOf("SELECT 1 FROM fl_terminal_drayage returned");
String returned = page.substring(start, page.indexOf("))", start));
var data = new JdbcDataSource();
data.setURL("jdbc:h2:mem:return-scope-" + java.util.UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
var db = new JdbcTemplate(data);
db.execute("CREATE TABLE fl_container_booking(id BIGINT,actual_empty_reported_at TIMESTAMP)");
db.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
db.execute("CREATE TABLE fl_carrier_schedule_node_fact(container_booking_id BIGINT,standard_node VARCHAR(40),actual_at TIMESTAMP)");
db.execute("INSERT INTO fl_container_booking VALUES(1,CURRENT_TIMESTAMP),(2,NULL),(3,NULL),(4,NULL)");
db.execute("INSERT INTO fl_terminal_drayage VALUES(2,CURRENT_TIMESTAMP)");
db.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(3,'EMPTY_RETURNED',CURRENT_TIMESTAMP),(4,'EMPTY_RETURNED',NULL)");
assertThat(db.queryForList("SELECT b.id FROM fl_container_booking b WHERE EXISTS ("+returned+") ORDER BY b.id",Long.class))
.containsExactly(2L,3L);
assertThat(db.queryForList("SELECT b.id FROM fl_container_booking b WHERE NOT EXISTS ("+returned+") ORDER BY b.id",Long.class))
.containsExactly(1L,4L);
}
@Test
@DisplayName("货柜映射平台级最终表且状态只从装柜计划读取")
void mapsFinalBookingContract() throws Exception {
@@ -0,0 +1,50 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/** 分托结算直接验证实际Mapper,箱序号不代表物理已装托盘。 */
class DropshipSplitRepositoryTest {
/** 已装箱位于序号末尾时,仍只迁移另一托的箱,跨客户同码不受影响。 */
@Test void transfersOnlyUnloadedPhysicalCartons() throws Exception {
var data = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var db = new JdbcTemplate(data);
db.execute("CREATE TABLE fl_inbound_carton_label(id BIGINT,tenant_id BIGINT,inbound_id BIGINT,order_id BIGINT,inbound_code VARCHAR(50),batch_status VARCHAR(20),total_carton_count INT,detail_package_count INT,box_code VARCHAR(50))");
db.execute("CREATE TABLE fl_dropship_pallet_carton(id BIGINT,tenant_id BIGINT,inbound_id BIGINT,pallet_id BIGINT,carton_label_id BIGINT)");
db.update("INSERT INTO fl_inbound_carton_label VALUES(1,10,31,100,'IN-1','ACTIVE',4,4,'BOX-1'),(2,10,31,100,'IN-1','ACTIVE',4,4,'BOX-2'),(3,10,31,100,'IN-1','ACTIVE',4,4,'BOX-3'),(4,10,31,100,'IN-1','ACTIVE',4,4,'BOX-4'),(5,20,31,200,'IN-2','ACTIVE',1,1,'BOX-1')");
db.update("INSERT INTO fl_dropship_pallet_carton VALUES(11,10,31,802,1),(12,10,31,802,2),(13,10,31,801,3),(14,10,31,801,4),(15,20,31,802,5)");
var config = new MybatisConfiguration(); config.addMapper(DropshipPalletizationMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config);
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DropshipPalletizationMapper.class);
new DropshipSplitRepository(mapper).transferRemainder(10,31,32,101,"IN-1-1",2,List.of(801L));
assertThat(db.queryForList("SELECT id FROM fl_inbound_carton_label WHERE inbound_id=32 ORDER BY id",Long.class)).containsExactly(1L,2L);
assertThat(db.queryForList("SELECT id FROM fl_dropship_pallet_carton WHERE inbound_id=32 ORDER BY id",Long.class)).containsExactly(11L,12L);
assertThat(db.queryForObject("SELECT inbound_id FROM fl_inbound_carton_label WHERE id=5",Long.class)).isEqualTo(31L);
assertThat(db.queryForList("SELECT box_code FROM fl_inbound_carton_label WHERE inbound_id=32 ORDER BY id",String.class)).containsExactly("BOX-1","BOX-2");
// 下一柜重新读取封托内容时,货物身份必须归到余量子入库单和余量子订单。
db.execute("CREATE TABLE fl_dropship_pallet(id BIGINT,pallet_no VARCHAR(50),pallet_status VARCHAR(20))");
db.execute("INSERT INTO fl_dropship_pallet VALUES(802,'DP-802','SEALED')");
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD inbound_detail_id BIGINT DEFAULT 41");
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD sku_code VARCHAR(30) DEFAULT 'SKU-1'");
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD cargo_name VARCHAR(30) DEFAULT '货物'");
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD actual_sku_quantity INT DEFAULT 1");
db.execute("CREATE TABLE fl_dropship_pallet_carton_sku(carton_id BIGINT,inbound_detail_id BIGINT,sku_code VARCHAR(30),cargo_name VARCHAR(30),actual_quantity INT)");
db.execute("CREATE TABLE fl_inbound_order(id BIGINT,tenant_id BIGINT,order_id BIGINT,order_no VARCHAR(50),inbound_code VARCHAR(50),warehouse_id BIGINT,channel_code VARCHAR(30),channel_name VARCHAR(30),destination_warehouse_id BIGINT,destination_code VARCHAR(30),destination_name VARCHAR(30),customer_name VARCHAR(30),actual_gross_weight_kg DECIMAL,actual_net_weight_kg DECIMAL,actual_volume_cbm DECIMAL,total_package_count INT)");
db.execute("INSERT INTO fl_inbound_order(id,tenant_id,order_id,order_no,inbound_code,total_package_count) VALUES(32,10,101,'ORDER-1-1','IN-1-1',2)");
var remainder = mapper.sealedPalletLines(802);
assertThat(remainder).hasSize(1);
assertThat(remainder.get(0).getInboundId()).isEqualTo(32L);
assertThat(remainder.get(0).getOrderId()).isEqualTo(101L);
assertThat(remainder.get(0).getInboundDetailId()).isEqualTo(41L);
assertThat(remainder.get(0).getCartonCount()).isEqualTo(2);
}
}
@@ -37,7 +37,8 @@ class FbaPricingFeeTransitionTest {
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)");
"process_status VARCHAR(32),fee_status VARCHAR(32),measure_review_status VARCHAR(32) DEFAULT 'APPROVED'," +
"chargeable_weight_kg DECIMAL(20,6),chargeable_volume_cbm DECIMAL(20,6),deleted BOOLEAN DEFAULT FALSE)");
repository = new FbaPricingRepository();
ReflectionTestUtils.setField(repository, "inbounds", session.getMapper(InboundPricingMapper.class));
}
@@ -50,7 +51,8 @@ class FbaPricingFeeTransitionTest {
/** 费用确认和发送不得提前完成一件代发组托或回退已有履约进度。 */
@ParameterizedTest
@ValueSource(strings = {"FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
@ValueSource(strings = {"WAIT_PRICING",
"FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
void confirmsAndSendsWithoutAdvancingPalletization(String processStatus) {
insertInbound(processStatus, "CALCULATED");
@@ -58,7 +60,7 @@ class FbaPricingFeeTransitionTest {
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");
assertState(processStatus, "SENT");
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isZero();
}
@@ -71,12 +73,10 @@ class FbaPricingFeeTransitionTest {
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");
insertInbound("WAIT_PALLETIZE", "CALCULATED");
assertThat(repository.markInboundConfirmed(20002L, 88L)).isZero();
sql.update("UPDATE fl_inbound_order SET deleted=TRUE WHERE id=88");
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
@@ -91,6 +91,64 @@ class FbaPricingFeeTransitionTest {
assertState("WAIT_SPLIT", "CONFIRMED");
}
/** 提前试算保留测量进度,但审核前不能确认和冻结费用。 */
@ParameterizedTest
@ValueSource(strings = {"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_SPLIT"})
void calculationPreservesMeasurementWorkflow(String processStatus) {
insertInbound(processStatus, "UNPRICED");
boolean approved = "WAIT_SPLIT".equals(processStatus);
sql.update("UPDATE fl_inbound_order SET measure_review_status=? WHERE id=88",
approved ? "APPROVED" : "PENDING");
assertThat(repository.markInboundCalculated(10001L, 88L, processStatus, "KG",
new java.math.BigDecimal("570.5"), new java.math.BigDecimal("3.6894"),
new java.math.BigDecimal("616.13"))).isEqualTo(1);
assertState(processStatus, "CALCULATED");
assertThat(repository.markInboundConfirmed(10001L, 88L)).isEqualTo(approved ? 1 : 0);
assertState(processStatus, approved ? "CONFIRMED" : "CALCULATED");
}
/** 即使主状态已经升级,确认也须原子校验实际测量审核结果。 */
@ParameterizedTest
@ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"})
void rejectsConfirmationWhenMeasurementIsNotApproved(String reviewStatus) {
insertInbound("WAIT_SPLIT", "CALCULATED");
sql.update("UPDATE fl_inbound_order SET measure_review_status=? WHERE id=88", reviewStatus);
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
assertState("WAIT_SPLIT", "CALCULATED");
}
/** 提前试算被拒绝确认后,审核通过仍可按正常费用链重新试算、确认和发送。 */
@Test
void pendingCalculationCanBeRepricedAndPublishedAfterMeasurementApproval() {
insertInbound("MEASURE_PENDING", "UNPRICED");
sql.update("UPDATE fl_inbound_order SET measure_review_status='PENDING' WHERE id=88");
assertThat(repository.markInboundCalculated(10001L, 88L, "MEASURE_PENDING", "KG",
new java.math.BigDecimal("570.5"), new java.math.BigDecimal("3.6894"),
new java.math.BigDecimal("616.13"))).isEqualTo(1);
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
assertState("MEASURE_PENDING", "CALCULATED");
sql.update("UPDATE fl_inbound_order SET process_status='WAIT_SPLIT',measure_review_status='APPROVED' WHERE id=88");
assertThat(repository.markInboundCalculated(10001L, 88L, "WAIT_SPLIT", "KG",
new java.math.BigDecimal("580"), new java.math.BigDecimal("4"),
new java.math.BigDecimal("668"))).isEqualTo(1);
assertThat(repository.markInboundConfirmed(10001L, 88L)).isEqualTo(1);
assertThat(repository.markInboundFeeSent(10001L, 88L, "WAIT_SPLIT")).isEqualTo(1);
assertState("WAIT_SPLIT", "SENT");
assertThat(sql.queryForObject("SELECT chargeable_weight_kg FROM fl_inbound_order WHERE id=88",
java.math.BigDecimal.class)).isEqualByComparingTo("668");
}
/** 即使确认费用已完成,测量审核未通过也不能在持久化阶段发送。 */
@ParameterizedTest
@ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"})
void rejectsSendingUnapprovedMeasurement(String reviewStatus) {
insertInbound("MEASURE_PENDING", "CONFIRMED");
sql.update("UPDATE fl_inbound_order SET measure_review_status=? WHERE id=88", reviewStatus);
assertThat(repository.markInboundFeeSent(10001L, 88L, "MEASURE_PENDING")).isZero();
assertState("MEASURE_PENDING", "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,?,?)",
@@ -165,6 +165,7 @@ class FbaPricingServiceTest {
}
@Test
/** 试算生成费用但保留原履约进度。 */
@DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商")
void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() {
readyRepository();
@@ -186,7 +187,7 @@ class FbaPricingServiceTest {
assertThat(response.conversionFactor()).isEqualByComparingTo("167");
assertThat(response.roundingScale()).isEqualTo(6);
assertThat(response.roundingMode()).isEqualTo("HALF_UP");
assertThat(response.nextProcessStatus()).isEqualTo("FEE_CONFIRMING");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_PRICING");
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository).insertFee(fee.capture());
assertThat(fee.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION");
@@ -560,6 +561,34 @@ class FbaPricingServiceTest {
org.mockito.Mockito.verifyNoInteractions(priceBookEstimator);
}
@Test
void manualQuoteFreezesDatabaseMillisecondPrecisionForExactReplay() {
readyRepository();
InboundPricingEntity inbound = inbound("62.500", "0.3840");
inbound.setDestinationType("PRIVATE");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(301L);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement);
when(measurementRepository.listItems(TENANT, 301L)).thenReturn(List.of(new InboundMeasurementItemEntity()));
when(repository.completeIdempotency(any(), eq(501L), any())).thenReturn(1);
Clock original = (Clock) org.springframework.test.util.ReflectionTestUtils.getField(service, "clock");
var preciseClock = Clock.fixed(Instant.parse("2026-08-03T02:00:00.123456789Z"), ZoneId.of("Asia/Shanghai"));
org.springframework.test.util.ReflectionTestUtils.setField(service, "clock", preciseClock);
try {
var result = service.manualQuote(TENANT, OPERATOR, INBOUND,
new ManualQuoteRequest(new BigDecimal("62.500"), new BigDecimal("0.3840"),
BigDecimal.ONE, "私人地址测试人工报价"), "private-precision");
assertThat(result.calculatedAt().getNano()).isEqualTo(123000000);
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository).insertFee(fee.capture());
assertThat(fee.getValue().getCreatedAt()).isEqualTo(result.calculatedAt());
assertThat(fee.getValue().getUpdatedAt()).isEqualTo(result.calculatedAt());
} finally {
org.springframework.test.util.ReflectionTestUtils.setField(service, "clock", original);
}
}
@Test
@DisplayName("已有匹配价格时拒绝绕过价格表创建人工报价")
void rejectsManualQuoteWhenRateExists() {
@@ -602,7 +631,7 @@ class FbaPricingServiceTest {
/** 待组托发送费用保留主状态,重复请求不重复生成费用事件。 */
@ParameterizedTest
@CsvSource({"FEE_CONFIRMING,WAIT_PALLET", "WAIT_PALLETIZE,WAIT_PALLETIZE"})
@CsvSource({"FEE_CONFIRMING,FEE_CONFIRMING", "WAIT_PALLETIZE,WAIT_PALLETIZE"})
@DisplayName("重复发送只返回首次结果且不会重复写Outbox")
void sendIsIdempotentAndDoesNotDuplicateOutbox(String processStatus, String expectedStatus) {
InboundPricingEntity inbound = inbound("10", "1");
@@ -659,6 +688,39 @@ class FbaPricingServiceTest {
eq(TENANT), eq(INBOUND), eq(501L), eq(1), eq(NOW));
}
/** 测量未审核前拒绝单独确认,不留下阻断审核后初算的已确认费用。 */
@ParameterizedTest
@ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"})
void rejectsConfirmingBeforeMeasurementApproval(String reviewStatus) {
InboundPricingEntity inbound = inbound("570.500", "3.6894");
inbound.setProcessStatus("MEASURE_PENDING");
inbound.setMeasureReviewStatus(reviewStatus);
inbound.setFeeStatus("CALCULATED");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
assertThatThrownBy(() -> service.confirm(TENANT, OPERATOR, INBOUND, "confirm-pending"))
.isInstanceOf(BizException.class).hasMessageContaining("先完成测量审核");
verify(repository, never()).confirmFee(anyLong(), anyLong(), anyLong(), any());
verify(repository, never()).markInboundConfirmed(anyLong(), anyLong());
verify(repository, never()).insertIdempotency(any());
}
/** 测量未审核前拒绝发布,不能产生已发送快照或财务事件。 */
@ParameterizedTest
@ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"})
void rejectsSendingBeforeMeasurementApproval(String reviewStatus) {
InboundPricingEntity inbound = inbound("570.500", "3.6894");
inbound.setProcessStatus("MEASURE_PENDING");
inbound.setMeasureReviewStatus(reviewStatus);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
assertThatThrownBy(() -> service.send(TENANT, OPERATOR, INBOUND, "send-pending"))
.isInstanceOf(BizException.class).hasMessageContaining("先完成测量审核");
verify(repository, never()).sendFee(anyLong(), anyLong(), anyLong(), any());
verify(repository, never()).insertIdempotency(any());
verify(financeOutbox, never()).publishInbound(anyString(), anyLong(), anyLong(), anyLong(), anyInt(), any());
}
@Test
@DisplayName("同一幂等键用于不同试算时点会冲突")
void rejectsReusedIdempotencyKeyWithDifferentRequest() {
@@ -101,6 +101,61 @@ class LoadingCompletedOutboxServiceTest {
assertThat(first.eventVersion()).isEqualTo(3);
}
/** 同类追加照片不阻断预检和发布,每类冻结最新有效照片并保持重试快照稳定。 */
@Test
void freezesLatestActivePhotoPerTypeDuringPreflightAndPublication() throws Exception {
Row value = row(11, 100, 101);
value.setPlannedPackageCount(1);
value.setPickedPackageCount(1);
when(snapshots.previewRows(90L)).thenReturn(List.of(value));
when(snapshots.rows(90L)).thenReturn(List.of(value));
CabinetLoadingEvidenceEntity voided = evidence("PHOTO_STEP_3", 7);
voided.setStatus("VOID");
CabinetLoadingEvidenceEntity inactive = evidence("PHOTO_STEP_3", 8);
inactive.setActiveFlag(null);
// 与 Mapper 的 evidence_type、id 升序一致,后到的作废或失效照片不能替代有效照片。
when(cabinetEvidence.byContainer(91L)).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1), evidence("PHOTO_STEP_2", 2),
evidence("PHOTO_STEP_3", 3), evidence("PHOTO_STEP_3", 6), voided, inactive,
evidence("PHOTO_STEP_4", 4), evidence("SEAL", 5)));
service.requirePublishable(90L);
verify(outbox, org.mockito.Mockito.never()).insert(
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
service.publish(90L);
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captured = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox, org.mockito.Mockito.times(2)).insert(captured.capture());
EventEnvelope first = mapper.readValue(captured.getAllValues().get(0).getPayloadJson(), EventEnvelope.class);
EventEnvelope replay = mapper.readValue(captured.getAllValues().get(1).getPayloadJson(), EventEnvelope.class);
assertThat(first.payload().path("loadingEvidence")).hasSize(5);
assertThat(first.payload().at("/loadingEvidence/2/evidenceType").asText()).isEqualTo("PHOTO_STEP_3");
assertThat(first.payload().at("/loadingEvidence/2/fileId").asLong()).isEqualTo(6L);
assertThat(replay.payloadHash()).isEqualTo(first.payloadHash());
}
/** 多张同类照片不能替代缺失类别,作废照片仍不计入五类完整性。 */
@Test
void rejectsMissingPhotoTypeEvenWhenMoreThanFivePhotosRemain() {
Row value = row(11, 100, 101);
value.setPlannedPackageCount(1);
value.setPickedPackageCount(1);
when(snapshots.previewRows(90L)).thenReturn(List.of(value));
when(snapshots.rows(90L)).thenReturn(List.of(value));
CabinetLoadingEvidenceEntity voided = evidence("PHOTO_STEP_4", 4);
voided.setStatus("VOID");
when(cabinetEvidence.byContainer(91L)).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1), evidence("PHOTO_STEP_2", 2),
evidence("PHOTO_STEP_3", 3), evidence("PHOTO_STEP_3", 6), evidence("PHOTO_STEP_3", 7),
voided, evidence("SEAL", 5)));
assertThatThrownBy(() -> service.requirePublishable(90L)).hasMessageContaining("五类照片不完整");
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("五类照片不完整");
verify(outbox, org.mockito.Mockito.never()).insert(
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
}
/** 非 ISO 柜号能生成当前 V3 事件,并保持冻结柜号原值。 */
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "LOCAL-2026-001"})
@@ -189,13 +244,46 @@ class LoadingCompletedOutboxServiceTest {
verify(manifests, org.mockito.Mockito.never()).labelPool(11L, 100L);
}
@Test void privateAddressFreezesAsOverseasWithOriginalCartonIdentity() throws Exception {
Row value = row(11, 100, 101);
value.setDestinationType("PRIVATE");
value.setDestinationWarehouseId(null);
value.setDestinationWarehouseCode(null);
value.setDestinationWarehouseName("私人地址");
when(snapshots.rows(90L)).thenReturn(List.of(value));
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox).insert(captor.capture());
EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
assertThat(event.payload().at("/orders/0/destinationType").asText()).isEqualTo("OVERSEAS");
assertThat(event.payload().at("/orders/0/destinationWarehouseId").isNull()).isTrue();
assertThat(event.payload().at("/orders/0/destinationWarehouseCode").isNull()).isTrue();
assertThat(event.payload().at("/orders/0/destinationWarehouseName").asText()).isEqualTo("私人地址");
assertThat(event.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101");
assertThat(event.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101");
assertThat(event.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-101");
verify(manifests).labelPool(11L, 100L);
}
@Test void rejectsMixedPrivateAndOverseasDestinationsInOneInbound() {
Row privateAddress = row(11, 100, 101);
privateAddress.setDestinationType("PRIVATE");
when(snapshots.rows(90L)).thenReturn(List.of(privateAddress, row(11, 100, 102)));
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("目的类型必须唯一");
verify(outbox, org.mockito.Mockito.never()).insert(
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
}
@Test void rejectsUnfrozenDestinationType() {
Row value = row(11, 100, 101);
value.setDestinationType(null);
when(snapshots.rows(90L)).thenReturn(List.of(value));
assertThatThrownBy(() -> service.publish(90L))
.hasMessageContaining("目的类型必须唯一且为 FBA 或 OVERSEAS");
.hasMessageContaining("目的类型必须唯一且为 FBA、OVERSEAS 或 PRIVATE");
verify(outbox, org.mockito.Mockito.never()).insert(
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
}
@@ -46,6 +46,7 @@ class PdaPickSettlementServiceTest {
@MockBean private OrderFulfillmentSplitClient orderSplits;
@MockBean private InboundHoldGuard inboundHoldGuard;
@MockBean private DropshipPalletizationMapper dropshipPallets;
@MockBean private com.baiyue.firstleg.repository.DropshipSplitRepository dropshipSplits;
@Resource private PdaPickSettlementService service;
@Test
@@ -266,6 +267,29 @@ class PdaPickSettlementServiceTest {
&& request.lines().size() == 1 && request.lines().get(0).packageCount() == 20));
}
@Test
void settlesOneSealedPalletAndMovesOnlyUnloadedPalletsToRemainder() {
var picked = item(11L, 21L, 31L, 41L, 2, 2, "PICKED");
picked.setUnitType("SEALED_PALLET"); picked.setPalletId(801L); picked.setPalletNo("DP-1");
resolved(List.of(picked));
var physical = new DropshipPalletizationMapper.PlanPalletMemberRow();
physical.setPalletId(801L); physical.setInboundId(31L); physical.setAccountId(10001L);
physical.setAvailablePackageCount(2);
when(dropshipPallets.palletInboundMembersByPalletIds(any())).thenReturn(List.of(physical));
when(splits.lockParent(31L)).thenReturn(parent(4));
when(splits.fulfillmentType(10001L, 31L)).thenReturn("ONE_CLICK_DROPSHIP");
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 4)));
when(allocations.lockConfirmed(10001L, 31L)).thenReturn(List.of(allocation(1L, 1, 501L, 4)));
writes(2, 2);
service.settle(1L, 9L, "operator");
verify(dropshipSplits).transferRemainder(eq(10001L), eq(31L), anyLong(), eq(1002L),
eq("IN-001-1"), eq(2), eq(List.of(801L)));
verify(splits, never()).transferRemainingCartonLabels(anyLong(), anyLong(), anyLong(), anyLong(), anyString(), anyInt(), anyInt());
verify(splits).updateMeasurementItemForLoaded(10001L, 51L, 2);
verify(splits).markSourceLoaded(10001L, 31L, 2, 9L, 3);
}
@Test
void onlyPureSkippedInboundRestoresCandidate() {
LoadingPickItemEntity item = item(12L, 22L, 32L, 42L, 8, 0, "SKIPPED");
@@ -148,14 +148,13 @@ class SplitPreloadServiceTest {
}
@Test
void ordinaryLoadingAllocationStillRequiresWaitingSplit() {
void otherCabinetMayReuseExistingPreloadWhileSourceIsLoading() {
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3));
PreloadPlanEntity plan = new PreloadPlanEntity();
plan.setStatus("PENDING");
when(repository.findPlan(10, 20)).thenReturn(plan);
assertThatThrownBy(() -> service.ensurePendingForLoadingAllocation(10, 20, 7, "admin"))
.hasMessageContaining("仅待分柜订单");
service.ensurePendingForLoadingAllocation(10, 20, 7, "admin");
verify(repository, never()).createPlan(any());
}
@@ -36,7 +36,7 @@ class WorkflowBoundaryTest {
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure);
}
/** 报关完成或提前上传资料仍留在出口报关,实际开船后才查询精确海外来源。 */
/** 未开船也查询尾程建单事实,未产生尾程来源时仍保留国内阶段。 */
@Test void completedCustomsAndClearanceUploadWaitForActualDeparture() {
var repository=mock(WorkflowFactRepository.class);
var downstream=mock(LastlegWorkflowClient.class);
@@ -45,12 +45,15 @@ class WorkflowBoundaryTest {
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
when(repository.facts(List.of(1L))).thenReturn(List.of(
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true)));
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of()));
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS");
verifyNoInteractions(downstream);
verify(downstream).read(any(),any());
clearInvocations(downstream);
when(repository.facts(List.of(1L))).thenReturn(List.of(
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,true)));
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS");
verifyNoInteractions(downstream);
verify(downstream).read(any(),any());
clearInvocations(downstream);
when(repository.facts(List.of(1L))).thenReturn(List.of(
new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true,true)));
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
@@ -64,4 +67,18 @@ class WorkflowBoundaryTest {
verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1)))));
}
/** 理货审核建单后,未补录开船与清关资料也必须返回出库待办。 */
@Test void outboundBeforeDepartureIsReadAndClassified() {
var repository=mock(WorkflowFactRepository.class);
var downstream=mock(LastlegWorkflowClient.class);
var service=new WorkflowTabService(repository,downstream);
var inbound=new InboundOrderEntity();inbound.setId(1L);inbound.setTenantId(11L);
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
when(repository.facts(List.of(1L))).thenReturn(List.of(
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,false,false)));
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
new WorkflowFacts.Overseas(11,1,true,true,false,true))));
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("OUTBOUND_DOCUMENT");
verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1)))));
}
}
@@ -30,12 +30,12 @@ class WorkflowBusinessTest {
assertThat(WorkflowTabService.classify(uploaded,"DELIVERED",unloaded))
.contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS");
}
/** 清关前不能被海外建单事实提前推进,清关后出库与派送依次替换待办。 */
/** 出库与派送按尾程事实展示,不受清关资料限制。 */
@Test void downstreamFactsCannotBypassClearanceAndOutboundReplacesQueues() {
var pending=new WorkflowFacts.Overseas(11,1,true,true,false,false);
var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,false);
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",pending)).containsExactly("SAILING_CLEARANCE");
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",shipped)).containsExactly("SAILING_CLEARANCE");
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",pending)).containsExactly("OUTBOUND_DOCUMENT","SAILING_CLEARANCE");
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",shipped)).containsExactly("DELIVERING","SAILING_CLEARANCE");
assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",pending)).containsExactly("OUTBOUND_DOCUMENT");
assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",shipped)).containsExactly("DELIVERING");
assertThat(WorkflowTabService.classify(fact(1,true),"DELIVERED",shipped)).containsExactly("SIGNED");
+7 -6
View File
@@ -44,7 +44,7 @@
| 自动生成托盘、资源生成失败与重试 | `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;详细规则定向查 `docs/business-index.md` 的“人工计划自动生成托盘与资源重试” |
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| 跨客户同仓预约、日历 | `InboundPlanController.java`;在途/外接货跟进与覆盖见`appointmentplanning/` |
| PDA 入仓、柜号签收、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java` → `inboundplan/pda/service/ReceivingContainerService.java`;门禁与状态定向查 `docs/business-index.md` 的“PDA 入仓、实收、上架与完成” |
| PDA 入仓、私人地址收货、实收与上架完成 | `inboundplan/pda/controller/ReceivingContainerController.java` → `inboundplan/pda/service/ReceivingContainerService.java`;门禁与状态定向查 `docs/business-index.md` 的“PDA 入仓、实收、上架与完成” |
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` |
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` |
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java` |
@@ -56,7 +56,8 @@
| 贴标申请、箱唛/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`、`dispatch/controller/LooseboardCargoController.java`;列表与加货接口分开,发布规则查拼车主契约 |
| 排车散板提货/派送执行与取证 | `dispatch/controller/DispatchLooseboardExecutionController.java`、`dispatch/service/DispatchLooseboardExecutionService.java`;契约 `src/main/resources/openapi/dispatch-looseboard-execution-v1.yaml` |
| 平台排车查询、整车四块详情、审核与实载 | `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` |
@@ -65,7 +66,7 @@
| 货物废弃、待审锁箱、实际确认扣减 | `discard/controller/CargoDiscardController.java` |
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java` |
| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java` |
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java` |
| 异常回仓建单与 PDA 分批实收 | `exceptionorder/controller/ExceptionOrderController.java`、`PdaExceptionReturnController.java`;契约 `src/main/resources/openapi/exception-return-inbound-v1.yaml` |
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/` |
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java` |
| 海外业务费用来源、费用明细审核与重试 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasManualFeeService.java`;目录入口 `financefee/controller/PlatformOverseasFeeCatalogController.java`,细则查详细索引同主题 |
@@ -74,10 +75,10 @@
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` |
| 海外仓报价、月度汇率、车辆主数据 | `masterdata/quote/` |
| 司机档案、证照上传/审核、资格预览与历史 | `fleet/driver/PlatformDriverController.java`、`DriverService.java`;OpenAPI `fleet-drivers-v1.yaml`,表 `ll_fleet_driver` / `ll_fleet_driver_history`;资格规则 `fleet/domain/DriverQualificationPolicy.java` |
| 车辆运维:保养、加油、维修、故障、救援、异常 | `fleet/maintenance/PlatformVehicleCareController.java`、`VehicleCareService.java`;合同`fleet-vehicle-records-v1.yaml`。按车/类型隔离,单位/币种明确,处理不等付款;创建/附件幂等,更正留版本历史,不改主档里程/状态。维修救援经`financefee/fleet/VehicleCareFinanceAdapter`入公司成本,Finance为付款权威,未关联UNLINKED,不生成客户应收 |
| 车次计划、车/司机分配与占用、客户排车关联 | `fleet/trip/PlatformFleetTripController.java`、`FleetTripService.java`;OpenAPI `fleet-trips-v1.yaml`;`FleetDispatchGateway` 由 `dispatch/fleet/DispatchFleetAdapter.java` 实现,`FleetDispatchBindingGuard` 控制子单独立修改/发车;车辆通过 `VehicleRepository` 和 `VehicleAssignmentGuard` 协作,fleet不直读车辆持久化适配器 |
| 车辆运维:保养、加油、维修、故障、救援、异常 | `fleet/maintenance/PlatformVehicleCareController.java`、`VehicleCareService.java`;合同 `fleet-vehicle-records-v1.yaml`。维修救援经 `financefee/fleet/VehicleCareFinanceAdapter` 入公司成本;Finance 为付款权威,未关联为 UNLINKED,不生成客户应收;权限、幂等与更正见详细索引同主题 |
| 车次计划、车/司机占用与排车关联 | `fleet/trip/PlatformFleetTripController.java`、`FleetTripService.java`;协作入口 `dispatch/fleet/DispatchFleetAdapter.java`、`FleetDispatchBindingGuard`、`VehicleAssignmentGuard`,不穿透车辆持久化;契约 `fleet-trips-v1.yaml` |
| 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java`;两级审核、依据失效与零扫码退回规则查当前排车主契约 |
| 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 |
| 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;来源、实收及资金边界查 `fleet-trips-v1.yaml` 与详细索引同主题 |
| 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 |
| 实物装载、逐段容量与双端整车发车 | `dispatch/service/DispatchLoadingDepartureService.java`、`DispatchLoadingDocumentService.java`;双端使用 `recordVersion`;装车确认、照片齐全后的自动派送、容量与幂等只查当前排车主契约,无公开 `/depart` |
| 车次货物结案、车辆和司机自动释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;全车派送完成自动结案释放车辆和司机,提货全部到仓及本土卡车完成同事务释放;不等待二次确认,不改变车辆主档状态,收款不触发释放 |
+2 -2
View File
@@ -32,7 +32,7 @@
| 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 |
| 内部令牌保护 | `security/InternalServiceTokenGuard.java` |
| 统一认证与撤销 | 复用 common-security 的Redis会话及Auth/IAM权威指纹校验,故障拒绝访问;Lastleg不保存独立授权投影 |
| 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 |
| 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;私人地址可无目的仓 ID/编码,票内地址名称须非空且一致,托盘分配按来源订单独立归属,接柜仓仍须有效绑定;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 |
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。普通 OVERSEAS 允许在本票已预留托盘间按现场实际分配箱数,逐托建议箱数不作为收货上限,仍校验本票总箱数、单托66箱、目的仓及客户归属;库存按已扫箱码的SKU和真实托盘归属入账。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,托盘校验和提交均只确认本票已预留绑定。Admin 入库单详情可按行打印已预留真实托盘标签,复用海外托盘列表权限和标签PDF接口,不生成第二个托盘码 |
@@ -60,7 +60,7 @@
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口按 `merchant:cross-border:procurement:list/create` 拆分且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购、换标、人工海外费用和仓租通过 `WALLET_COMMAND_REQUESTED` 请求 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得同步调用 Finance 钱包或在 Lastleg 自建余额账本 |
| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasManualFeeService.java`、`OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`;费用必须匹配当前客户的真实服务单,公共费用目录通过 Firstleg 内部接口读取。头程附加费审核后进入账单;海外人工应收创建先冻结、审核后扣款,驳回或取消释放,扣款成功后仅登记本地已收来源,不再推送财务账单;采购、换标原业务收费仍按其独立资金身份处理。头程账单费用及供应商应付经直接 Outbox 投递 Finance,余额应收仅通过资金指令取得扣款回执,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml`。新菜单列表权限与财务编辑、审核按钮权限分离;本地检查不能代替真实资金、账单联调 |
| 自动仓租及出库资金门禁 | `storagerent/`;实际 PDA 收货建立原计租批次,头程自有海外仓目的地与仓储转运用中转阶梯,退货用原托阶梯;每24小时进一天,中转部分出库按原批次份额计费,退货原托余一箱仍按整托计费,同托同计租日不重复收取,余货不重置库龄。退货已计费截止日与实际出库、费用生成同事务推进,后续只收新增库龄;并发推进后旧预占必须释放重建。实际出库命令前单独提交资金预校验,冻结成功后才允许扣库存并生成待审核仓租,订单费用与海外费用管理展示实际入库、出库、原计算额及实扣额;审核前可调价且说明必填,批准后沿原冻结按实额结算,驳回释放冻结;全部仓租使用余额及授信。客户仓租配置优先,无配置按报价表默认,内部查询失败不得回退。预占保存配置版本、完整阶梯、USD/CNY 原币及结算汇率快照,实际扣款不读最新价格。发布及真实验收清单见 `docs/overseas-fees-storage-rent-release.md` |
| 自动仓租及出库资金门禁 | `storagerent/`;实际 PDA 收货建立原计租批次,头程自有海外仓目的地与仓储转运用中转阶梯,退货用原托阶梯;每24小时进一天,头程无逐箱测量时按原批次份额计费;仓储转运的排车、正常出库及废弃按实际箱长宽高汇总CBM计费,部分出库需明确箱唛,缺尺寸或来源不符则阻断,冻结快照保存实箱与体积;退货原托余一箱仍按整托计费,同托同计租日不重复收取,余货不重置库龄。退货已计费截止日与实际出库、费用生成同事务推进,后续只收新增库龄;并发推进后旧预占必须释放重建。实际出库命令前单独提交资金预校验,冻结成功后才允许扣库存并生成待审核仓租,订单费用与海外费用管理展示实际入库、出库、原计算额及实扣额;审核前可调价且说明必填,批准后沿原冻结按实额结算,驳回释放冻结;全部仓租使用余额及授信。客户仓租配置优先,无配置按报价表默认,内部查询失败不得回退。预占保存配置版本、完整阶梯、USD/CNY 原币及结算汇率快照,实际扣款不读最新价格。发布及真实验收清单见 `docs/overseas-fees-storage-rent-release.md` |
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口按 `merchant:cross-border:domestic-trucking:list/create/edit` 拆分 |
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口按 `merchant:cross-border:pallet-shipping:list/create/edit` 拆分;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 |
@@ -18,6 +18,8 @@
头程同单同仓多次实际入库合并为一个计租批次,统一从首次实际入库起算;补入库只追加箱数和计费体积,不重置库龄、免费期或已出库记录。单位体积沿用首次入库比例,后续修改订单体积不改写既有计租比例。其他业务保留各批实际入库时间分别计龄。头程未逐箱清点时,部分出库按来源单据箱数比例估算体积,审核前可调整金额并填写原因。本次不合并历史已存在的计租批次。
仓储转运库存的正常出库、排车及废弃按本次实际箱长×宽×高÷1,000,000汇总CBM,再乘原入库库龄对应的客户阶梯累计单价,不按原批次箱数平均分摊体积。部分出库必须指明实际箱唛;整托可按该仓位全部可追溯实箱求和。缺少尺寸、箱数不符或无法核对原实收批次时阻断预校验。箱唛与实测体积一并固化在冻结快照,变化后须重新预校验。正常出库同事务保存实箱SHIPPED状态,废弃由原作业保存DISCARDED状态,避免后续重复计租。历史已生成费用保持原快照,不自动重算。本次修改不需要新增数据库字段,尚未发布到测试环境。
| 规则 | 计费阶梯(USD) | 数量口径 |
|---|---|---|
| 中转仓租 | 第1–7天免费;8–30天0.8;31–60天1;61–90天1.5;91天起2 | CBM/天,按阶梯累加 |
@@ -11,8 +11,8 @@ public interface DispatchWholePdaReadMapper {
record Picked(Long itemId,long packages) { }
record Invalid(Long dispatchId,long quantity) { }
record Carton(Long itemId,Long cartonId,String cartonMark,String status,long productQuantity) { }
/** 精确单号或有界阶段列表均先在SQL限制绑定仓。 */
@Select("<script>SELECT * FROM ll_dispatch_order WHERE overseas_warehouse_id=#{warehouse} AND status='ACTIVE' "
/** 绑定仓仅下发仓内作业;本土卡车与到仓取货排车继续在后台执行。 */
@Select("<script>SELECT * FROM ll_dispatch_order WHERE overseas_warehouse_id=#{warehouse} AND status='ACTIVE' AND COALESCE(task_type,'DELIVERY') NOT IN ('LOCAL_TRUCK','PICKUP') "
+"<choose><when test='number != null'>AND dispatch_no=#{number} </when><when test='stage != null'>AND stage=#{stage} </when>"
+"<otherwise>AND (stage IN('LOADING','AFTER_LOADING','DELIVERING') OR (stage='BEFORE_LOADING' AND review_state='LOADING_PENDING')) </otherwise></choose>ORDER BY updated_at DESC,id DESC LIMIT 201</script>")
List<DispatchAggregate> headers(@Param("warehouse")String warehouse,@Param("number")String number,@Param("stage")String stage);
@@ -54,10 +54,11 @@ public final class DispatchViews {
List<DispatchDeliveryModels.DeliveryReference> deliveries,
Instant createdAt, Instant updatedAt, String remark) { }
/** 散板按客户和商业订单汇总;来源键集合仅供只读明细关联,不替代作业来源身份。 */
public record OrderDetail(String customerId, String customerName, String orderNo, String inboundCode,
BigDecimal grossWeightKg, Integer palletCount, String destinationFbaWarehouse,
BigDecimal volumeCbm, String containerNo, String sourceKey,
String sourceBusinessType, String sourceBusinessNo) { }
String sourceBusinessType, String sourceBusinessNo, List<String> sourceKeys) { }
/** 页头元数据和四个内容对象固定,附加能力不扩展为新页签。 */
public record Detail(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion,
@@ -6,11 +6,12 @@ import java.util.List;
/** 客户照片仅查询已核准客户与原单的有效承运关联。 */
@Repository
public class MerchantTransportPhotoRepository {
public class MerchantTransportPhotoRepository implements com.baiyue.lastleg.looseboard.port.LooseboardDispatches {
private final MerchantTransportPhotoMapper mapper;
/** 注入本域查询入口。 */
public MerchantTransportPhotoRepository(MerchantTransportPhotoMapper mapper){this.mapper=mapper;}
/** 返回原散板单实际关联的排车身份。 */
@Override
public List<String> looseboardDispatches(String customer,String number){return mapper.looseboardDispatches(customer,number);}
/** 返回原卡车单有效运输任务。 */
public List<String> truckDispatches(String customer,String number){return mapper.truckDispatches(customer,number);}
@@ -962,8 +962,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
return batches.stream().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());
return DispatchDestinationAppointments.booked(b,confirmed);
}).toList();
}
@@ -1230,9 +1229,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
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));
}
/** 混托多订单无法真实分摊重量体积时保持未知,不能给每票复制整托总量。 */
/** 散板同客户同商业订单只展示一行,逐托来源仍保留用于原作业和运营明细关联。 */
private List<DispatchViews.OrderDetail> orderDetails(List<DispatchItem> cargo) {
var result=new ArrayList<DispatchViews.OrderDetail>();
var groups=new LinkedHashMap<List<String>,List<OrderDisplayContribution>>();
var businessSources=looseboardDisplay.sources(cargo.stream().map(i->new com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.Reference(i.getCustomerId(),i.getOrderNo(),i.getPlanPalletId())).toList());
for(var item:cargo) {
var source=read(item.getSourceSnapshotJson(),SourceDisplay.class);
@@ -1240,11 +1239,46 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(orders.isEmpty()) orders=Collections.singletonList(null);
for(var order:orders) {
var business=looseboardDisplay.find(businessSources,item.getCustomerId(),order,item.getPlanPalletId());
result.add(new DispatchViews.OrderDetail(item.getCustomerId(),item.getCustomerName(),business==null?order:business.orderNo(),business==null?item.getInboundCode():null,orders.size()==1?item.getGrossWeightKg():null,
orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,business==null?source.containerNo():null,item.getSourceKey(),business==null?item.getSourceBusinessType():"LOCAL_LOOSEBOARD",business==null?item.getSourceBusinessNo():business.orderNo()));
var detail=new DispatchViews.OrderDetail(item.getCustomerId(),item.getCustomerName(),business==null?order:business.orderNo(),business==null?item.getInboundCode():null,orders.size()==1?item.getGrossWeightKg():null,
orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,business==null?source.containerNo():null,item.getSourceKey(),business==null?item.getSourceBusinessType():"LOCAL_LOOSEBOARD",business==null?item.getSourceBusinessNo():business.orderNo(),item.getSourceKey()==null?List.of():List.of(item.getSourceKey()));
// 商业订单必须来自权威关联,不能按LB前缀或跨客户同号合并。
var key=business==null?List.of("SOURCE",Integer.toString(groups.size())):List.of("LOOSEBOARD",item.getCustomerId(),business.orderNo());
String pallet=item.getWarehousePalletId()!=null?"WAREHOUSE:"+item.getWarehousePalletId():item.getPlanPalletId()!=null?"PLAN:"+item.getPlanPalletId():"SOURCE:"+Objects.toString(item.getSourceKey(),Objects.toString(item.getId(),Integer.toString(cargo.indexOf(item))));
var members=groups.computeIfAbsent(key,ignored->new ArrayList<>());
if(members.stream().noneMatch(member->member.item()==item))members.add(new OrderDisplayContribution(item,detail,pallet));
}
}
return List.copyOf(result);
var palletOrders=new HashMap<String,Set<List<String>>>();
groups.forEach((key,members)->members.forEach(member->palletOrders.computeIfAbsent(member.palletKey(),ignored->new HashSet<>()).add(key)));
var sharedPallets=palletOrders.entrySet().stream().filter(entry->entry.getValue().size()>1).map(Map.Entry::getKey).collect(Collectors.toSet());
return groups.entrySet().stream().map(entry->"LOOSEBOARD".equals(entry.getKey().get(0))?looseboardOrderDetail(entry.getValue(),sharedPallets):entry.getValue().get(0).detail()).toList();
}
/** 仅在展示层保存原来源与实托身份,不改写排车货物或库存。 */
private record OrderDisplayContribution(DispatchItem item,DispatchViews.OrderDetail detail,String palletKey) { }
/** 同托只计一次;混托无法分摊、缺测量或同托测量冲突均保持未知。 */
private DispatchViews.OrderDetail looseboardOrderDetail(List<OrderDisplayContribution> members,Set<String> sharedPallets) {
var first=members.get(0).detail();
var pallets=members.stream().collect(Collectors.groupingBy(OrderDisplayContribution::palletKey,LinkedHashMap::new,Collectors.toList()));
Integer palletCount=members.stream().anyMatch(m->m.detail().palletCount()==null)?null:pallets.size();
var destinations=members.stream().map(m->m.detail().destinationFbaWarehouse()).filter(Objects::nonNull).filter(s->!s.isBlank()).distinct().toList();
return new DispatchViews.OrderDetail(first.customerId(),first.customerName(),first.orderNo(),null,
orderMeasurement(pallets.values(),sharedPallets,DispatchViews.OrderDetail::grossWeightKg),palletCount,destinations.isEmpty()?null:String.join("、",destinations),
orderMeasurement(pallets.values(),sharedPallets,DispatchViews.OrderDetail::volumeCbm),null,first.sourceKey(),first.sourceBusinessType(),first.sourceBusinessNo(),
members.stream().flatMap(m->m.detail().sourceKeys().stream()).distinct().toList());
}
/** 每个实托必须有一致测量才能汇总,不能把未知量当零或重复累加整托量。 */
private java.math.BigDecimal orderMeasurement(Collection<List<OrderDisplayContribution>> pallets,Set<String> sharedPallets,java.util.function.Function<DispatchViews.OrderDetail,java.math.BigDecimal> measurement) {
var total=java.math.BigDecimal.ZERO;
for(var members:pallets) {
if(sharedPallets.contains(members.get(0).palletKey()))return null;
var value=measurement.apply(members.get(0).detail());
if(value==null || members.stream().anyMatch(m->measurement.apply(m.detail())==null || value.compareTo(measurement.apply(m.detail()))!=0))return null;
total=total.add(value);
}
return total;
}
/** 合并Fleet权威资源、本趟照片和维护资料,不写入车辆或司机主档。 */
@@ -1308,14 +1342,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
public long pickedRemotePackages(DispatchAggregate row){return looseboardFacts==null?0:looseboardFacts.pickedRemotePackages(row);}
/** 仓内实物作业范围,普通未收货计划仍保留并按原规则阻断。 */
public List<DispatchItem> warehouseItems(DispatchAggregate row){return looseboardFacts==null?repository.cargo(row.getId()):looseboardFacts.warehouseItems(row.getId());}
/** 异地取货仍占用本趟容量;使用仓内实测加散板申报,不补造实托或实测。 */
/** 混排核对已知占用;未知重量体积不阻塞仓内出库,也不补造实测。 */
public void requireRemoteCapacity(DispatchAggregate row,List<LoadingMeasurement> measured){
var ids=remoteItemIds(row);if(ids.isEmpty())return;
var remote=repository.cargo(row.getId()).stream().filter(i->ids.contains(i.getId())).toList();
var capacity=read(row.getResourcesJson(),Resources.class).vehicle().capacity();
if(remote.stream().anyMatch(i->i.getGrossWeightKg()==null || i.getVolumeCbm()==null) || measured.stream().anyMatch(m->m.weightKg()==null || m.volumeCbm()==null))throw conflict("混排实测与异地取货申报货量未明确");
var weight=remote.stream().map(DispatchItem::getGrossWeightKg).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add).add(measured.stream().map(LoadingMeasurement::weightKg).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add));
var volume=remote.stream().map(DispatchItem::getVolumeCbm).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add).add(measured.stream().map(LoadingMeasurement::volumeCbm).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add));
// 只累加已知值作为占用下限;某项未知不能掩盖其余已知货量已经超载的事实。
var weight=remote.stream().map(DispatchItem::getGrossWeightKg).filter(Objects::nonNull).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add).add(measured.stream().map(LoadingMeasurement::weightKg).filter(Objects::nonNull).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add));
var volume=remote.stream().map(DispatchItem::getVolumeCbm).filter(Objects::nonNull).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add).add(measured.stream().map(LoadingMeasurement::volumeCbm).filter(Objects::nonNull).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add));
if(remote.size()+measured.stream().mapToInt(LoadingMeasurement::palletSpaces).sum()>capacity.palletSpaces() || weight.compareTo(capacity.maxLoadKg())>0 || volume.compareTo(capacity.maxVolumeCbm())>0)throw conflict("仓内装载与异地取货合计超过本车容量");
}
/** 取货照片保存事务复验已确认仓内实载与当前异地申报。 */
@@ -26,13 +26,13 @@ final class DispatchDestinationAppointments {
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 confirmed = booked.stream().filter(a -> key.equals(a.destinationKey()) && ("SUCCESS".equals(a.status()) || "REGISTERED".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 at = matching.stream().filter(a -> "SUCCESS".equals(a.status())).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));
@@ -40,6 +40,15 @@ final class DispatchDestinationAppointments {
return List.copyOf(result);
}
/** 已约批次可回显已登记编号;只有当前有效收货确认才能提供审核用确认时间。 */
static FbaAppointment booked(com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.BatchRow batch, ReceivingWindow confirmed) {
var registered = "SUCCESS".equals(batch.status()) && batch.confirmationNo() != null && !batch.confirmationNo().isBlank();
var number = confirmed != null ? confirmed.confirmationNo() : registered ? batch.confirmationNo() : null;
return new FbaAppointment(batch.batchKey(),batch.batchNo(),number,batch.destinationKey(),batch.warehouseCode(),
confirmed != null ? "SUCCESS" : number != null ? "REGISTERED" : "PENDING",confirmed == null ? null : confirmed.startAt(),
confirmed == null ? "尚无当前有效收货确认" : null,batch.customerId(),List.of());
}
/** 保存允许暂缺编号,但不能把别的目的仓写入本车。 */
static void validate(Maintenance maintenance, List<FbaAppointment> destinations) {
if (maintenance == null) return;
@@ -57,7 +57,7 @@ public class DispatchLooseboardExecutionService {
if(!(phase==Phase.PICKUP?item.canPickup():item.canArrive()))throw conflict("当前阶段不能上传该节点照片");
return files.upload(required(number).getId(),kind(phase),actor.operatorId(),file);
}
/** 父锁、公共版本、不可变命令和照片推进在同一事务保存;失败不留下假发车。 */
/** 同客户散板原单的各托照片原子保存,公共版本与命令身份仍由整车统一控制。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public Detail save(Actor actor,String number,long itemId,Phase phase,Save request){
workflow.lockForArrangement(actor,number);var row=required(number);
@@ -69,7 +69,9 @@ public class DispatchLooseboardExecutionService {
}
if(row.getRecordVersion().longValue()!=request.recordVersion())throw conflict("排车版本已变化,请刷新后重试");
var cargo=facts.cargo(row.getId()).stream().filter(c->c.item().getId()==itemId).findFirst().orElseThrow(()->conflict("散板明细不属于本车"));
var current=detail(row);var item=current.items().stream().filter(i->i.itemId().equals(Long.toString(itemId))).findFirst().orElseThrow();
var members=facts.cargo(row.getId()).stream().filter(c->Objects.equals(c.item().getCustomerId(),cargo.item().getCustomerId()) && Objects.equals(c.orderNo(),cargo.orderNo())).toList();
var memberIds=members.stream().map(c->c.item().getId().toString()).collect(java.util.stream.Collectors.toSet());
var current=detail(row);var item=current.items().stream().filter(i->memberIds.contains(i.itemId())).findFirst().orElseThrow();
if(!(phase==Phase.PICKUP?item.canPickup():item.canArrive()))throw conflict("当前阶段不能保存该节点照片");
if(request.fileIds().size()>20 || new HashSet<>(request.fileIds()).size()!=request.fileIds().size())throw conflict("每个节点最多保存20张不重复照片");
var previous=phase==Phase.PICKUP?item.pickupPhotos():item.arrivalPhotos();
@@ -77,15 +79,18 @@ public class DispatchLooseboardExecutionService {
if(request.fileIds().isEmpty() && ("DELIVERED".equals(row.getStage()) || phase==Phase.PICKUP && (facts.pickupConfirmed(row) || !item.arrivalPhotos().isEmpty())))throw conflict("已确认取货或送达的节点至少保留一张凭证,可先追加新照片再删除旧照片");
var old=facts.history(row.getId());
boolean pickupRecorded=old.stream().anyMatch(e->e.phase()==Phase.PICKUP && matches(e,cargo) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty());
boolean arrivalRecorded=old.stream().anyMatch(e->e.phase()==Phase.ARRIVAL && matches(e,cargo) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty());
if(old.stream().filter(e->!matches(e,cargo) || e.phase()!=phase || !Objects.equals(e.cargoHash(),row.getCargoHash()) || e.reviewRound()!=row.getReviewRound()).flatMap(e->e.files().stream()).anyMatch(f->request.fileIds().contains(f.fileId())))throw conflict("照片已绑定其他节点或明细,不能重复使用");
boolean arrivalRecorded=members.stream().allMatch(c->old.stream().anyMatch(e->e.phase()==Phase.ARRIVAL && matches(e,c) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty()));
if(old.stream().filter(e->!memberIds.contains(e.itemId()) || !Objects.equals(e.customerId(),cargo.item().getCustomerId()) || !Objects.equals(e.orderNo(),cargo.orderNo()) || e.phase()!=phase || !Objects.equals(e.cargoHash(),row.getCargoHash()) || e.reviewRound()!=row.getReviewRound()).flatMap(e->e.files().stream()).anyMatch(f->request.fileIds().contains(f.fileId())))throw conflict("照片已绑定其他节点或明细,不能重复使用");
var photos=request.fileIds().stream().map(id->files.metadata(row.getId(),kind(phase),id)).toList();
if(photos.stream().anyMatch(f->!Set.of("image/jpeg","image/png").contains(f.contentType())))throw conflict("取货和送达凭证必须为照片");
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);
var evidence=new Evidence(Long.toString(itemId),cargo.item().getCustomerId(),cargo.orderNo(),phase,row.getCargoHash(),row.getReviewRound(),photos,at,actor.operatorId(),request.idempotencyKey(),hash);
long original=row.getRecordVersion();row.setRecordVersion(original+1);row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));
if(repository.saveState(row,original)!=1)throw conflict("排车版本竞争失败");
if(repository.history(IdWorker.getId(),row,"LOOSEBOARD_PHOTO",actor.operatorId(),phase.name(),write(evidence),row.getUpdatedAt())!=1)throw conflict("散板照片保存失败");
// 一次订单操作保留逐托来源证据,原送达量统计和结案校验继续按明细精确核对。
for(var member:members){
var evidence=new Evidence(member.item().getId().toString(),member.item().getCustomerId(),member.orderNo(),phase,row.getCargoHash(),row.getReviewRound(),photos,at,actor.operatorId(),request.idempotencyKey(),hash);
if(repository.history(IdWorker.getId(),row,"LOOSEBOARD_PHOTO",actor.operatorId(),phase.name(),write(evidence),row.getUpdatedAt())!=1)throw conflict("散板照片保存失败");
}
if(phase==Phase.ARRIVAL && !photos.isEmpty()){
if(!arrivalRecorded)orders.progress(cargo.item().getCustomerId(),cargo.orderNo(),actor.operatorId(),facts.deliveredPackages(cargo.item().getCustomerId(),cargo.orderNo()));
if(current.direct() && "DELIVERING".equals(row.getStage()) && facts.complete(row,Phase.PICKUP) && facts.complete(row,Phase.ARRIVAL)){
@@ -171,7 +176,15 @@ public class DispatchLooseboardExecutionService {
boolean canArrive=active && (mixedPhotoStage || Set.of("DELIVERING","DELIVERED").contains(row.getStage())) && (c.item().getWarehousePalletId()!=null || confirmed && !pickup.isEmpty());
return new Item(c.item().getId().toString(),c.item().getCustomerId(),c.orderNo(),Objects.toString(c.item().getDestinationWarehouseCode(),c.item().getDeliveryAddress()),c.item().getPackageCount(),pickup,arrival,canPickup,canArrive,pendingInbound.contains(c.item().getId())?c.orderNo()+":还没拣货入库":null,remote.contains(c.item().getId()));
}).toList();boolean ready=active && !confirmed && (direct?"LOADING".equals(row.getStage()) && "APPROVED".equals(row.getReviewState()):!remote.isEmpty() && Set.of("AFTER_LOADING","DELIVERING").contains(row.getStage())) && facts.complete(row,Phase.PICKUP);
return new Detail(row.getDispatchNo(),row.getRecordVersion(),row.getStage(),direct,items,ready,confirmed);
var groups=new LinkedHashMap<List<String>,List<Item>>();
for(var item:items)groups.computeIfAbsent(List.of(item.customerId(),item.orderNo()),key->new ArrayList<>()).add(item);
var orders=groups.values().stream().map(group->{
var first=group.get(0);
return new Item(first.itemId(),first.customerId(),first.orderNo(),first.destination(),group.stream().mapToInt(Item::packages).sum(),
group.stream().flatMap(i->i.pickupPhotos().stream()).distinct().toList(),group.stream().flatMap(i->i.arrivalPhotos().stream()).distinct().toList(),
group.stream().allMatch(Item::canPickup),group.stream().allMatch(Item::canArrive),group.stream().map(Item::warehouseNotice).filter(Objects::nonNull).findFirst().orElse(null),group.stream().anyMatch(Item::pickupRequired));
}).toList();
return new Detail(row.getDispatchNo(),row.getRecordVersion(),row.getStage(),direct,orders,ready,confirmed);
}
/** 业务原单范围取主流程认证后的本车,不接受客户端指定归属。 */
private DispatchAggregate required(String number){var row=repository.find(number);if(row==null)throw conflict("排车单不存在");return row;}
@@ -26,7 +26,7 @@ public class DispatchPdaQueryService {
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchViews.PdaTask task(Actor actor,String number) {
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);
var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");var tasks=assemble(actor,rows);if(tasks.isEmpty())throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return tasks.get(0);
}
/** 按收货已确认的实托批量读取;未交接的原PLAN不推算实收仓位或箱数。 */
private List<DispatchViews.PdaTask> assemble(Actor actor,List<DispatchAggregate> roots) {
@@ -42,6 +42,8 @@ public class DispatchPdaQueryService {
var consumed=hasRelabel?sourceOrigins.pickedAll(ids).stream().collect(Collectors.toMap(p->p.sourceId(),p->p.packages())):Map.<Long,Long>of();
for(var root:roots) {
var remote=workflow.remoteItemIds(root);var items=cargo.getOrDefault(root.getId(),List.of()).stream().filter(i->!remote.contains(i.getId())).toList();var detail=new ArrayList<DispatchViews.PdaItem>();
// 纯异地取货没有仓内拣货,不向PDA推送空任务。
if(items.isEmpty())continue;
boolean sourceValid=true;var expectedHold=new HashMap<String,Long>();
for(var source:frozen)if(Objects.equals(source.getDispatchOrderId(),root.getId())) {
long used=consumed.getOrDefault(source.getId(),0L),left=n(source.getPackageCount())-used;
@@ -7,8 +7,8 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.*;
import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*;
/** 已发布日常派送排车复用正式加货命令,不创建第二车次或库存。 */
@Service
@@ -16,8 +16,10 @@ public class LooseboardCargoService {
private final LooseboardCargoRepository cargo;
private final DispatchAggregateRepository dispatches;
private final DispatchAggregateService service;
public LooseboardCargoService(LooseboardCargoRepository cargo, DispatchAggregateRepository dispatches, DispatchAggregateService service) {
this.cargo=cargo; this.dispatches=dispatches; this.service=service;
private final DispatchCommandHasher hashes;
/** 加货候选复用权威来源及版本摘要,不在客户端拼造来源。 */
public LooseboardCargoService(LooseboardCargoRepository cargo, DispatchAggregateRepository dispatches, DispatchAggregateService service,DispatchCommandHasher hashes) {
this.cargo=cargo; this.dispatches=dispatches; this.service=service;this.hashes=hashes;
}
private String warehouse(String number) {
var row=dispatches.find(number);
@@ -25,12 +27,37 @@ public class LooseboardCargoService {
throw new ResponseStatusException(HttpStatus.CONFLICT,"该排车已退出散板需求池,请刷新列表");
return row.getOverseasWarehouseId();
}
/** 按完整散板订单分页;同单各托组成一次选择,不能因托盘跨页漏选。 */
@Transactional(readOnly=true)
public DispatchPlanningModels.CandidateList candidates(DispatchContracts.Actor actor,String number,int page,int size) {
String warehouse=warehouse(number); var plans=cargo.readyPlans(warehouse);
if(plans.isEmpty())return new DispatchPlanningModels.CandidateList(List.of(),Instant.now(),true,0,page,size,0);
if(plans.size()>500)throw new ResponseStatusException(HttpStatus.CONFLICT,"同仓待排散板过多,请先在排车工作台处理");
return service.candidates(actor,new DispatchPlanningModels.CandidateQuery(warehouse,plans,null,null,null,null,page,size,"ALL"));
var sources=new ArrayList<Candidate>();
for(int sourcePage=1;;sourcePage++){
var result=service.candidates(actor,new CandidateQuery(warehouse,plans,null,null,null,null,sourcePage,50,"ALL"));
sources.addAll(result.candidates());
if(sourcePage>=result.pages() || result.candidates().isEmpty())break;
}
var groups=new LinkedHashMap<List<String>,List<Candidate>>();
for(var source:sources)groups.computeIfAbsent(List.of(source.customerId(),Objects.requireNonNullElse(source.orderNo(),source.sourceKey())),key->new ArrayList<>()).add(source);
var orders=groups.values().stream().map(this::orderCandidate).toList();
long offset=(long)(page-1)*size;
return new CandidateList(orders.stream().skip(offset).limit(size).toList(),Instant.now(),orders.size()<=size,orders.size(),page,size,(orders.size()+size-1L)/size);
}
/** 合并真实来源闭包和版本,显示箱数按本单来源相加,提交仍逐来源复验。 */
private Candidate orderCandidate(List<Candidate> rows){
var first=rows.get(0);var members=new LinkedHashMap<String,SelectionGroupSource>();
var pallets=new LinkedHashMap<String,SelectionGroupPallet>();var reasons=new LinkedHashSet<String>();boolean complete=true;
for(var row:rows){
reasons.addAll(row.blockReasons());var group=row.selectionGroup();
if(group==null){complete=false;reasons.add("订单货物来源尚未完整,请重新读取");continue;}
complete&=group.complete();reasons.addAll(group.blockReasons());
for(var member:group.sources())members.putIfAbsent(member.sourceKey(),member);
for(var pallet:group.pallets())pallets.putIfAbsent(pallet.warehousePalletId()==null?group.groupKey()+":"+pallet.palletNo():pallet.warehousePalletId().toString(),pallet);
}
var group=new SelectionGroup("LOOSEBOARD_ORDER:"+hashes.source(List.of(first.customerId(),Objects.requireNonNullElse(first.orderNo(),first.sourceKey()))),hashes.source(rows.stream().map(Candidate::selectionGroup).toList()),List.copyOf(members.values()),List.copyOf(pallets.values()),complete,List.copyOf(reasons));
return new Candidate(first.selection(),first.sourceKey(),first.sourceVersion(),first.customerId(),first.customerName(),first.warehouseId(),first.warehouseCode(),first.warehouseName(),first.orderNo(),first.inboundCode(),first.planNo(),null,first.containerNo(),first.destination(),rows.stream().mapToLong(Candidate::availablePackages).sum(),null,null,"ORDER_SOURCES",rows.stream().flatMap(r->r.appointmentBatches().stream()).distinct().toList(),List.copyOf(reasons),group);
}
@Transactional
public DispatchContracts.CommandResult add(DispatchContracts.Actor actor,String number,DispatchCommands.AddCargo request) {
@@ -14,6 +14,10 @@ public interface ExceptionWorkflowMapper {
/** 本车可登记的派送单,客户跟随真实派送记录。 */
@Select("SELECT customer_id,delivery_no FROM ll_delivery_order WHERE dispatch_order_id=#{id} AND status IN ('DELIVERING','REJECTED','DELIVERED') ORDER BY id")
List<Delivery> deliveries(long id);
/** 取消本单时核对同一客户及派送是否仍有其他活动退回。 */
@Select("SELECT COUNT(*) FROM ll_exception_order WHERE customer_id=#{customer} AND delivery_order_id=#{delivery} " +
"AND exception_type='RETURN' AND id<>#{excluded} AND status IN ('PENDING','PROCESSING')")
long countOtherActiveReturns(@Param("customer") String customer, @Param("delivery") long delivery, @Param("excluded") long excluded);
/** 以异常号和冻结仓锁定现场任务。 */
@Select("SELECT * FROM ll_exception_order WHERE exception_no=#{number} AND overseas_warehouse_id=#{warehouse} LIMIT 1 FOR UPDATE")
ExceptionOrder lockWarehouse(@Param("warehouse") String warehouse, @Param("number") String number);
@@ -24,6 +24,10 @@ public class ExceptionWorkflowRepository {
}
/** 查询本车派送归属。 */
public List<ExceptionWorkflowMapper.Delivery> deliveries(long id) { return workflow.deliveries(id); }
/** 取消单据不释放同派送其他仍在处理的退回批次。 */
public boolean hasOtherActiveReturns(String customer, long delivery, long excluded) {
return workflow.countOtherActiveReturns(customer,delivery,excluded)>0;
}
/** 在客户范围锁定异常。 */
public ExceptionOrder lock(String customer, String number) { return exceptions.lockByNo(customer,number); }
/** 保存人工完结后的异常状态,保留关联单据和实际库存事实。 */
@@ -36,17 +36,24 @@ public class DispatchExceptionAdapter implements DispatchExceptionPort {
if(candidates.isEmpty())throw new ResponseStatusException(HttpStatus.CONFLICT,"本车没有可关联的派送来源");
if(warehouse) {
var seen=new HashSet<String>();
var grouped=new LinkedHashMap<Candidate,List<ExceptionOrderRequests.MarkReturn>>();
for(int index=0;index<selected.size();index++) {
var selection=selected.get(index);
if(selection==null || selection.items()==null || selection.items().isEmpty() || selection.items().size()>200 || !seen.add(selection.deliveryNo()))
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"每张派送单请选择一张来源订单及有效退回货物");
if(selection==null || selection.items()==null || selection.items().isEmpty() || selection.items().size()>200
|| !seen.add(selection.deliveryNo()+":"+selection.lastMileOrderId()))
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择不重复的来源订单及有效退回货物");
var source=candidates.stream().filter(c->Objects.equals(c.deliveryNo(),selection.deliveryNo()) && Objects.equals(c.lastMileOrderId(),selection.lastMileOrderId())).findFirst()
.orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"所选来源不属于当前排车"));
if(selection.items().stream().anyMatch(i->i==null || i.outboundPalletItemId()==null || i.packageCount()<1))throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"退回明细无效");
service.markReturn(source.customerId(),operator,operator,source.deliveryNo(),new ExceptionOrderRequests.MarkReturn(source.lastMileOrderId(),reason,"OVERSEAS_WAREHOUSE",null,
// 同派送的多张原订单一次锁定并整批冻结,不能让首单的退回中状态拦住后续订单。
var group=grouped.keySet().stream().filter(row->Objects.equals(row.customerId(),source.customerId())
&& Objects.equals(row.deliveryNo(),source.deliveryNo())).findFirst().orElse(source);
grouped.computeIfAbsent(group,ignored->new ArrayList<>()).add(new ExceptionOrderRequests.MarkReturn(source.lastMileOrderId(),reason,"OVERSEAS_WAREHOUSE",null,
selection.items().stream().map(i->new ExceptionOrderRequests.ReturnItem(i.outboundPalletItemId(),i.inventoryPositionId(),i.packageCount(),i.returnCartonId())).toList(),
"dispatch:"+dispatchId+":"+java.util.UUID.nameUUIDFromBytes(request.idempotencyKey().getBytes(java.nio.charset.StandardCharsets.UTF_8))+":"+index));
}
for(var entry:grouped.entrySet()) service.markReturns(entry.getKey().customerId(),operator,operator,
entry.getKey().deliveryNo(),entry.getValue());
} else for(var source:candidates) service.create(source.customerId(),operator,operator,
new ExceptionOrderRequests.Create(source.customerId(),null,source.lastMileOrderNo(),"REFUSED".equals(request.exceptionType().name())?"REJECT":"OTHER",reason,null,null));
}
@@ -216,7 +216,7 @@ public class ExceptionOrderService {
ExceptionOrderResponses.Relation relation = relation(scopedCustomerId, request.customerId(),
request.inboundCode(), request.lastMileOrderNo());
return insert(relation, null, normalizeType(request.exceptionType()), request.reason(), request.packageCount(),
request.skuCode(), null, null, "MANUAL", operatorId, operatorName);
request.skuCode(), null, null, "MANUAL", operatorId, operatorName, null);
}
/** 按原出库冻结普通仓位或原箱来源,整箱引用与数量在同一派送锁内复核。 */
@@ -227,7 +227,44 @@ public class ExceptionOrderService {
ExceptionOrderCommand replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim());
if (replay != null) return replayCreated(replay, requestHash);
DeliveryOrder delivery = lockForReturn(customerId, deliveryNo);
return markReturnLocked(customerId, operatorId, operatorName, delivery, request, requestHash, delivery.getStatus());
}
/** 同一派送可一次选择多张原订单;整批共用派送行锁,任一来源失败全部回滚。 */
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public List<ExceptionOrderResponses.Created> markReturns(String customerId, String operatorId, String operatorName,
String deliveryNo, List<ExceptionOrderRequests.MarkReturn> requests) {
if (requests == null || requests.isEmpty() || requests.size() > 200 || requests.stream().anyMatch(request -> request == null
|| request.lastMileOrderId() == null || request.idempotencyKey() == null || request.idempotencyKey().isBlank()
|| request.items() == null || request.items().isEmpty() || request.items().size() > 200
|| request.items().stream().anyMatch(item -> item == null || item.packageCount() < 1))
|| requests.stream().map(ExceptionOrderRequests.MarkReturn::idempotencyKey).distinct().count() != requests.size()
|| requests.stream().map(ExceptionOrderRequests.MarkReturn::lastMileOrderId).distinct().count() != requests.size())
throw badRequest("请选择不重复的来源订单及有效退回货物");
var replayed = new ArrayList<ExceptionOrderResponses.Created>();
for (var request : requests) {
var replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim());
if (replay != null) replayed.add(replayCreated(replay, hash(request)));
}
if (replayed.size() == requests.size()) return List.copyOf(replayed);
if (!replayed.isEmpty()) throw conflict("退回批次已有部分命令,请刷新后核对原结果");
DeliveryOrder delivery = lockForReturn(customerId, deliveryNo);
long packages = requests.stream().flatMap(request -> request.items().stream())
.mapToLong(ExceptionOrderRequests.ReturnItem::packageCount).reduce(0L, Math::addExact);
long signed = Objects.requireNonNullElse(delivery.getSignedPackages(), 0);
if (signed < 0 || delivery.getPackageCount() == null || packages > delivery.getPackageCount() - signed)
throw conflict("退回总箱数超过派送未签收数量");
var created = new ArrayList<ExceptionOrderResponses.Created>();
String originalStatus = delivery.getStatus();
for (var request : requests) created.add(markReturnLocked(customerId, operatorId, operatorName, delivery, request, hash(request), originalStatus));
return List.copyOf(created);
}
/** 调用方已锁定派送并校验批次总量,这里按原订单冻结真实来源并保存独立异常。 */
private ExceptionOrderResponses.Created markReturnLocked(String customerId, String operatorId, String operatorName,
DeliveryOrder delivery, ExceptionOrderRequests.MarkReturn request,
String requestHash, String originalDeliveryStatus) {
String deliveryNo = delivery.getDeliveryNo();
List<ExceptionOrderResponses.Relation> candidates = returnCandidates(delivery);
ExceptionOrderResponses.Relation selected = candidates.stream()
.filter(candidate -> Objects.equals(candidate.lastMileOrderId(), request.lastMileOrderId()))
@@ -248,7 +285,7 @@ public class ExceptionOrderService {
}
ExceptionOrderResponses.Created created = insert(selected, delivery, "RETURN", request.reason(),
packages, sku, returnDirection, request.returnLogisticsNo(),
"DELIVERY_RETURN", operatorId, operatorName);
"DELIVERY_RETURN", operatorId, operatorName, originalDeliveryStatus);
for (ExceptionOrderRequests.ReturnItem requestedItem : requested) {
ExceptionOrderResponses.ReturnSource source = available.stream()
@@ -284,7 +321,7 @@ public class ExceptionOrderService {
return created;
}
/** Caller keeps the delivery row locked until return creation commits. */
/** 调用方持有派送行锁,禁止活动退回批次之间重叠冻结。 */
DeliveryOrder lockForReturn(String customerId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
@@ -461,7 +498,7 @@ public class ExceptionOrderService {
private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery,
String type, String reason, Integer packages, String sku,
String direction, String logisticsNo, String sourceType,
String operatorId, String operatorName) {
String operatorId, String operatorName, String originalDeliveryStatus) {
if (packages != null && relation.availablePackages() != null && packages > relation.availablePackages()) {
throw conflict("异常箱数不能大于订单箱数 " + relation.availablePackages());
}
@@ -482,7 +519,8 @@ public class ExceptionOrderService {
value.setReturnLogisticsNo(trim(logisticsNo));
if (delivery != null) {
LastlegOrder tail = relation.lastMileOrderId() == null ? null : lastMileOrders.selectById(relation.lastMileOrderId());
value.setReturnContextJson(write(new ReturnContext(delivery.getStatus(), tail == null ? null : tail.getId(),
// 同批各单冻结批次开始前的派送状态,支持任意顺序取消并恢复。
value.setReturnContextJson(write(new ReturnContext(originalDeliveryStatus, tail == null ? null : tail.getId(),
tail == null ? null : tail.getSubOrderStatus(), tail == null ? null : tail.getAbnormal())));
}
if (StringUtils.hasText(direction)) {
@@ -695,6 +733,7 @@ public class ExceptionOrderService {
private record ReturnContext(String deliveryStatus, Long tailId, String tailStatus, Boolean abnormal) { }
/** 取消只恢复本原单;同批仍有未取消退回时继续保留派送退回中状态。 */
private void restoreCancelledReturn(ExceptionOrder order) {
if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return;
ReturnContext previous;
@@ -706,7 +745,8 @@ public class ExceptionOrderService {
if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) throw conflict("关联派送状态已变化,不能取消退回");
if (returnItems.findByException(order.getCustomerId(), order.getId()).stream().anyMatch(item -> ReturnReceiptPolicy.received(item) > 0))
throw conflict("已有实际退回入仓,不能取消退回事实");
delivery.setStatus(previous.deliveryStatus()); delivery.setUpdatedAt(LocalDateTime.now());
boolean otherReturns = workflow.hasOtherActiveReturns(order.getCustomerId(), delivery.getId(), order.getId());
delivery.setStatus(otherReturns ? "RETURN_PENDING" : previous.deliveryStatus()); delivery.setUpdatedAt(LocalDateTime.now());
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); deliveryOrders.updateById(delivery);
if (previous.tailId() != null) {
LastlegOrder tail = lastMileOrders.selectById(previous.tailId());
@@ -0,0 +1,169 @@
package com.baiyue.lastleg.export;
import java.util.Arrays;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
/** 导出按任务类型及字段转换中文;仓码、SKU、币种与自由文本原样保留。 */
final class LastlegExportLabels {
/** 中文导出映射仅提供静态转换。 */
private LastlegExportLabels() { }
private static final Map<String, String> WORKFLOW = Map.ofEntries(
Map.entry("PENDING_INBOUND", "待入库"),
Map.entry("PENDING_REVIEW", "待审核"),
Map.entry("PENDING_RECEIPT", "待签收入库"),
Map.entry("PARTIALLY_RECEIVED", "部分入库"),
Map.entry("INBOUND_COMPLETED", "全部入库"),
Map.entry("WAIT_PDA_RECEIVING", "入库测量中"),
Map.entry("INBOUND_MEASURING", "入库测量中"),
Map.entry("WAIT_CUSTOMER_CONFIRMATION", "待客户确认实测"),
Map.entry("APPROVED", "审核通过"),
Map.entry("PENDING_ASSIGNMENT", "待分配"),
Map.entry("WAIT_RELABEL", "待换标"),
Map.entry("WAIT_FBA_DESTINATION", "待确认目的仓"),
Map.entry("READY_FOR_DISPATCH", "待创建出库单"),
Map.entry("WAIT_OUTBOUND", "待出库"),
Map.entry("ALLOCATED", "已分配库存"),
Map.entry("OUTBOUNDED", "已出库"),
Map.entry("RECEIVED", "已收货"),
Map.entry("IN_TRANSIT", "运输中"),
Map.entry("DESTINATION_ASSIGNED", "已分配目的仓"),
Map.entry("INBOUND_PLANNED", "已生成入库计划"),
Map.entry("PENDING_PROCESSING", "待处理"),
Map.entry("PROCESSING", "处理中"),
Map.entry("COMPLETED", "已完成"),
Map.entry("REJECTED", "已驳回"),
Map.entry("CANCELLED", "已取消"),
Map.entry("DRAFT", "草稿"),
Map.entry("PARTIAL_INBOUND", "部分入库"),
Map.entry("SIGNED", "已签收"),
Map.entry("PICKING", "拣货中"),
Map.entry("PICKED", "已拣货"),
Map.entry("SHIPPED", "已出库"),
Map.entry("WAIT_DELIVERY", "待派送"),
Map.entry("PENDING_DELIVERY", "待派送"),
Map.entry("DELIVERING", "派送中"),
Map.entry("DELIVERED", "派送完成"),
Map.entry("RETURNED", "已退回"),
Map.entry("CUSTOMER_CONFIRM_PENDING", "待客户确认"),
Map.entry("READY_FOR_OUTBOUND", "待出库"),
Map.entry("NOT_REQUIRED", "无需换标"));
private static final Map<String, String> INVENTORY_BUSINESS = Map.ofEntries(
Map.entry("IN_TRANSIT", "在途入账"),
Map.entry("STOCK_IN", "入库"),
Map.entry("STOCK_IN_TRANSIT_RELEASE", "在途库存转入"),
Map.entry("HOLD", "库存暂扣"),
Map.entry("RELEASE", "解除暂扣"),
Map.entry("ALLOCATE", "库存分配"),
Map.entry("UNALLOCATE", "取消分配"),
Map.entry("PICK", "拣货"),
Map.entry("SHIP", "出库"),
Map.entry("ADJUST", "库存调整"),
Map.entry("STOCKTAKE_ADJUST", "盘点调整"),
Map.entry("RECEIVING_WAREHOUSE_CHANGED", "变更接仓点"),
Map.entry("INTERNAL_MOVE", "库内移库"),
Map.entry("RELABEL_PALLET_DISASSEMBLE", "换标拆托"),
Map.entry("PALLET_ASSEMBLE", "组托"),
Map.entry("PALLET_DISASSEMBLE", "拆托"),
Map.entry("PALLET_SWAP", "换托"),
Map.entry("PALLET_UNBIND", "解托"),
Map.entry("RETURN_STOCK_IN", "退回入库"),
Map.entry("DISCARD", "货物废弃"));
private static final Map<String, String> ORDER_TYPES = Map.ofEntries(
Map.entry("ONE_CLICK_DROPSHIP", "一件代发"),
Map.entry("FIRST_LEG", "头程订单"),
Map.entry("RELABEL_APPLICATION", "换标下单"),
Map.entry("WAREHOUSE_TRANSFER", "仓储转运"),
Map.entry("RETURN_RELABEL", "退货换标"),
Map.entry("DELIVERY_RETURN", "派送异常退回"),
Map.entry("LOCAL_LOOSEBOARD", "本土散板"),
Map.entry("CARGO_DISCARD", "货物废弃订单"),
Map.entry("REJECTED", "拒收订单"),
Map.entry("EXPRESS", "快递订单"));
private static final Map<String, String> NODES = Map.ofEntries(
Map.entry("WAIT_LOADING", "待装柜"),
Map.entry("LOADING", "装柜中"),
Map.entry("WAIT_CUSTOMS", "待出口报关"),
Map.entry("WAIT_OUTBOUND", "待国内出仓"),
Map.entry("DEPARTED_WAREHOUSE", "已离开国内仓库"),
Map.entry("IN_TRANSIT", "海运途中"),
Map.entry("ARRIVED_DESTINATION_PORT", "船舶已到目的港"),
Map.entry("WAITING_FOR_ARRIVAL", "待到港"),
Map.entry("ARRIVED", "已到港"),
Map.entry("PICKUP_READY", "已清关、可提柜"),
Map.entry("PICKUP_SCHEDULED", "已预约提柜"),
Map.entry("PICKED_UP", "已提柜"),
Map.entry("UNLOADED", "待还柜"),
Map.entry("EMPTY_RETURNED", "已还柜"),
Map.entry("AT_ORIGIN_PORT", "重箱已进入起运港"),
Map.entry("LOADED_ON_VESSEL", "已装船"),
Map.entry("DEPARTED_ORIGIN_PORT", "船舶已离开起运港"),
Map.entry("DISCHARGED_FROM_VESSEL", "已卸船、未清关"));
private static final Map<String, String> INVENTORY_STATUS = Map.ofEntries(
Map.entry("OUTSIDE", "仓外"),
Map.entry("UNBOUND", "未绑托"),
Map.entry("IN_TRANSIT", "在途"),
Map.entry("RECEIVING", "收货中"),
Map.entry("AVAILABLE", "可用"),
Map.entry("HOLD", "暂扣"),
Map.entry("HELD", "暂扣"),
Map.entry("ALLOCATED", "已分配"),
Map.entry("PICKED", "已拣货"),
Map.entry("SHIPPED", "已出库"),
Map.entry("DISCARDED", "已废弃"),
Map.entry("STOCK_IN", "已入库"),
Map.entry("EMPTY", "空置"),
Map.entry("OCCUPIED", "占用"));
/** 只转换明确的业务枚举列,避免将编号、币种和备注误当作状态。 */
static Object value(String type, String key, Object value) {
if (!(value instanceof String text) || text.isBlank()) return value;
return switch (type) {
case "LAST_MILE_ORDER_LIST" -> switch (key) {
case "currentStatus" -> label(text, WORKFLOW, "未知状态");
case "orderTypeName", "businessSource.label" -> label(text, ORDER_TYPES, "未知业务类型");
case "containerNode" -> label(text, NODES, "未知货柜节点");
case "relabelStatus" -> label(text, WORKFLOW, "未知换标状态");
case "palletMergeFlag" -> label(text, Map.of("REQUIRED", "待合托", "MERGED", "已合托"), "未标记");
case "subOrderStatus" -> subOrders(text);
default -> value;
};
case "OVERSEAS_INVENTORY_LEDGER" -> switch (key) {
case "businessType" -> label(text, INVENTORY_BUSINESS, "未知业务类型");
case "statusFrom", "statusTo" -> label(text, INVENTORY_STATUS, "未知库存状态");
default -> value;
};
case "OVERSEAS_QUOTE_LIST", "MONTHLY_EXCHANGE_RATE_LIST", "FLEET_QUOTE_LIST" -> switch (key) {
case "status" -> label(text, Map.of("ACTIVE", "启用", "INACTIVE", "停用", "DELETED", "已删除"), "未知状态");
case "details.kind" -> label(text, Map.of("PURCHASE", "车队采购报价", "SALE", "外接销售报价"), "未知用途");
default -> value;
};
case "VEHICLE_LIST" -> switch (key) {
case "status" -> label(text, Map.of("IN_SERVICE", "在役", "DISPATCHED", "外派中", "MAINTENANCE", "维修中", "INACTIVE", "停用", "RETURNED", "已归还"), "未知状态");
case "ownership" -> label(text, Map.of("OWNED", "自有", "DISPATCHED", "外派"), "未知车辆归属");
case "region" -> label(text, Map.of("DOMESTIC", "国内", "OVERSEAS", "海外"), "未知区域");
default -> value;
};
case "TRIP_OPERATIONS_LIST" -> "status".equals(key)
? label(text, Map.of("PLANNED", "计划中", "IN_TRANSIT", "运输中", "COMPLETED", "货物已结案", "CANCELLED", "已取消"), "未知状态") : value;
default -> value;
};
}
/** 子单状态可能由多个“业务类型:状态”组成,分别翻译而不破坏分组。 */
private static String subOrders(String value) {
Map<String, String> kinds = Map.of("INBOUND_PLAN", "入库单", "OUTBOUND", "出库单", "DELIVERY", "派送单", "RELABEL", "换标单", "WAREHOUSE_TRANSFER", "仓储转运", "RETURN_RELABEL", "退货换标");
return Arrays.stream(value.split("[;;]"))
.map(part -> {
String[] pair = part.split(":", 2);
return pair.length == 2 ? label(pair[0], kinds, "子单") + ":" + label(pair[1], WORKFLOW, "未知状态")
: label(part, WORKFLOW, "未知状态");
}).collect(Collectors.joining(";"));
}
/** 中文历史快照保持原文,未知英文枚举使用中文占位。 */
private static String label(String value, Map<String, String> labels, String fallback) {
return labels.getOrDefault(value.trim().toUpperCase(Locale.ROOT), value.matches(".*[一-鿿].*") ? value : fallback);
}
}
@@ -47,6 +47,7 @@ public class LastlegExportProviderService {
@Resource private Validator validator;
@Value("${baiyue.security.internal-service-token}") private String signingKey;
/** 校验平台导出快照,复用列表筛选并生成中文业务数据批次。 */
public ExportBatchResponse batch(String type, ExportBatchRequest request) {
if (request == null || !"PLATFORM".equals(request.appCode()) || !"0".equals(request.tenantId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅支持平台导出");
@@ -81,7 +82,7 @@ public class LastlegExportProviderService {
case "TRIP_OPERATIONS_LIST" -> operations.report(query(parameters, TripOperationsModels.Query.class), costVisible).page();
default -> throw invalid("不支持该导出类型");
};
List<Map<String, Object>> rows = page.records().stream().map(value -> row(value, columns)).toList();
List<Map<String, Object>> rows = page.records().stream().map(value -> row(type, value, columns)).toList();
int next = offset + rows.size();
if (rows.isEmpty() && next < page.total()) throw invalid("导出分页未推进");
return new ExportBatchResponse(1, hash(columns), snapshot, first ? columns : List.of(), rows,
@@ -98,7 +99,8 @@ public class LastlegExportProviderService {
} catch (IllegalArgumentException e) { throw invalid("导出筛选条件无效"); }
}
private Map<String, Object> row(Object value, List<ExportColumn> columns) {
/** 按显式列投影业务数据,仅对指定枚举字段进行中文转换。 */
private Map<String, Object> row(String type, Object value, List<ExportColumn> columns) {
JsonNode source = json.valueToTree(value);
Map<String, Object> result = new LinkedHashMap<>();
for (ExportColumn column : columns) {
@@ -106,12 +108,11 @@ public class LastlegExportProviderService {
for (String part : column.key().split("\\.")) node = node.path(part);
Object cell = node.isMissingNode() || node.isNull() ? null
: node.isBoolean() ? node.booleanValue() : node.isNumber() ? node.decimalValue() : node.asText();
result.put(column.key(), cell);
result.put(column.key(), LastlegExportLabels.value(type, column.key(), cell));
}
if (value instanceof com.baiyue.lastleg.dto.LastlegOrderSummary order) {
result.put("abnormal", Boolean.TRUE.equals(order.abnormal()) ? "是" : "否");
result.put("detained", Boolean.TRUE.equals(order.detained()) ? "是" : "否");
result.put("currentStatus", com.baiyue.lastleg.model.LastlegOrderStatus.labelOf(order.currentStatus()));
}
if (value instanceof TripOperationsModels.Row trip) {
result.put("destinations", trip.destinations().stream().map(TripOperationsModels.Destination::label).collect(Collectors.joining(" / ")));
@@ -34,29 +34,32 @@ public class FleetHandoverPdaQuery {
public record Status(String dispatchNo, String tripNo, String tripStatus, String tripRecordVersion,
FleetTransitEvidence.Transport originalDeparture, List<Delivery> deliveries, Handover latestHandover) { }
/** 交接只读范围按原发车仓内实载核对,异地取货量不冒充仓库派送。 */
public Status status(long warehouseId, String dispatchNo) {
var binding = bindings.findByDispatchNo(dispatchNo);
if (binding == null) throw notFound();
var trip = trips.findByNo(binding.getTripNo());
// Check token-bound warehouse before reading any customer's transport or historical data.
// 先校验令牌绑定仓,再读取客户运输及历史资料。
if (trip == null || !Objects.equals(trip.getWarehouseId(), warehouseId) || !Objects.equals(binding.getTripId(), trip.getId())) throw notFound();
if (!List.of("IN_TRANSIT", "COMPLETED").contains(trip.getStatus()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "尚未发车,请按原拣货发车流程作业");
var evidence = dispatches.transitEvidence(dispatchNo, false);
if (!Long.toString(warehouseId).equals(evidence.warehouseId())
|| !dispatchNo.equals(evidence.dispatchNo()) || !Objects.equals(binding.getPackageCount(), evidence.shippedPackages())) throw notFound();
|| !dispatchNo.equals(evidence.dispatchNo()) || evidence.shippedPackages()<=0) throw notFound();
var deliveryNumbers=new java.util.HashSet<String>();
if(evidence.deliveries()==null || evidence.deliveries().isEmpty() || evidence.deliveries().stream().anyMatch(row ->
row==null || row.cargo()==null || row.cargo().customerId()==null || row.cargo().customerId().isBlank()
|| !dispatchNo.equals(row.cargo().dispatchNo()) || row.cargo().deliveryNo()==null
|| !deliveryNumbers.add(row.cargo().deliveryNo()) || row.cargo().packages()<=0)
|| evidence.deliveries().stream().mapToLong(row->row.cargo().packages()).sum()!=evidence.shippedPackages())throw notFound();
)throw notFound();
var original = history.selectList(new LambdaQueryWrapper<FleetTripHistory>().eq(FleetTripHistory::getTripId, trip.getId())
.eq(FleetTripHistory::getAction, "DEPART"));
if (original.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "缺少唯一原始发车记录");
try {
var departed = json.readValue(original.get(0).getSnapshotJson(), FleetTripModels.AuditSnapshot.class).trip();
var originalSnapshot = json.readValue(original.get(0).getSnapshotJson(), FleetTripModels.AuditSnapshot.class);
var departed = originalSnapshot.trip();
if (departed == null || !trip.getTripNo().equals(departed.tripNo()) || !Long.toString(warehouseId).equals(departed.warehouseId())) throw notFound();
requireWarehouseCargo(dispatchNo,binding,evidence,originalSnapshot);
var originalTransport = new FleetTransitEvidence.Transport(departed.vehiclePlate(), departed.driverName(), departed.driverPhone());
var rows = evidence.deliveries().stream().map(row -> new Delivery(row.cargo().deliveryNo(), row.status(), row.cargo().packages(),
row.cargo().terminalAt() != null ? 0 : row.cargo().packages() - row.cargo().returnedCargo().stream().mapToInt(FleetHandoverPolicy.ReturnedCargo::packages).sum(),
@@ -71,6 +74,27 @@ public class FleetHandoverPdaQuery {
return new Status(dispatchNo, trip.getTripNo(), trip.getStatus(), trip.getRecordVersion().toString(), originalTransport, rows, handover);
} catch (JsonProcessingException error) { throw new IllegalStateException("交接记录读取失败", error); }
}
/** 用原始逐来源实载证明客户、目的地及箱数,不能仅放宽整车数量相等判断。 */
private static void requireWarehouseCargo(String number,FleetTripDispatch binding,FleetTransitEvidence evidence,FleetTripModels.AuditSnapshot snapshot){
long packages=evidence.deliveries().stream().mapToLong(d->d.cargo().packages()).sum();
if(snapshot.confirmedLoad()==null){
if(!Objects.equals(binding.getPackageCount(),evidence.shippedPackages()) || packages!=evidence.shippedPackages())throw notFound();
return;
}
var expected=new java.util.HashMap<List<String>,Long>();
if(snapshot.confirmedLoad().cargo()==null)throw notFound();
for(var cargo:snapshot.confirmedLoad().cargo())if(number.equals(cargo.dispatchNo())){
if(cargo.customerId()==null || cargo.destinationKey()==null || cargo.warehousePalletId()==null || cargo.packages()<=0)throw notFound();
expected.merge(List.of(cargo.customerId(),cargo.destinationKey()),(long)cargo.packages(),Long::sum);
}
var actual=new java.util.HashMap<List<String>,Long>();
for(var delivery:evidence.deliveries()){
var cargo=delivery.cargo();if(cargo.destinationKey()==null)throw notFound();
actual.merge(List.of(cargo.customerId(),cargo.destinationKey()),(long)cargo.packages(),Long::sum);
}
if(expected.isEmpty() || !expected.equals(actual) || binding.getPackageCount()==null || packages>evidence.shippedPackages() || evidence.shippedPackages()>binding.getPackageCount())throw notFound();
if(snapshot.dispatches()==null || snapshot.dispatches().stream().noneMatch(d->number.equals(d.dispatchNo()) && d.packageCount()==binding.getPackageCount()))throw notFound();
}
private static FleetTransitEvidence.Transport transport(FleetHandoverModels.Resource resource) {
return new FleetTransitEvidence.Transport(resource.vehiclePlate(), resource.driverName(), resource.driverPhone());
}
@@ -56,8 +56,9 @@ public final class OwnedBusinessModels {
String recordVersion,String dispatchRecordVersion,PhotoSummary photoSummary,Actions actions,List<String> sourceIssues,String taskType) {}
public record Summary(int dispatchCount,int afterLoadingCount,int deliveringCount,int deliveredCount) {}
public record ListResult(List<Row> records,int page,int size,int total,Summary summary) {}
/** 散板按商业订单展示,关联托盘与目的地完整保留,箱数只累加真实来源贡献。 */
public record Cargo(String sourceKey,String customerId,String customerName,String orderNo,
String inboundCode,String palletNo,String destinationKey,String packages) {}
String inboundCode,String palletNo,String destinationKey,String packages,List<String> destinationKeys) {}
public record Detail(Row row,DispatchViews.Detail dispatch,List<Cargo> cargo,List<Photo> photos) {}
public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,
Operations operations,List<Photo> photos) {}
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Search;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.CargoRow;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
@@ -68,14 +69,37 @@ public class OwnedBusinessQueryService {
public Detail detailForWrite(Actor actor,Source source) {
return detailForWrite(actor,source,OwnedBusinessScope.OWNED);
}
/** 写回执也执行固定归属校验,调用者已核对本域独立写权限。 */
/** 写回执也执行固定归属校验;散板明细复用原排车公开的商业订单归属。 */
public Detail detailForWrite(Actor actor,Source source,OwnedBusinessScope scope) {
eligible(source,scope);var ids=List.of(source.getId());var cargo=repository.cargo(ids);
var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor,scope);
var original=dispatch.detail(new DispatchContracts.Actor(actor.operatorId(),ActorMode.PLATFORM,null),source.getDispatchNo());
return new Detail(row,original,cargo.stream().map(c->new Cargo(c.getSourceKey(),c.getCustomerId(),c.getCustomerName(),
c.getOrderNo(),c.getInboundCode(),c.getPalletNo(),projection.destinationKey(c),String.valueOf(c.getPackageCount()))).toList(),
projection.photos(repository.record(source.getId())));
var looseboardOrders=new HashMap<List<String>,String>();
if(original!=null && original.orderDetails()!=null)for(var order:original.orderDetails()) {
if(!"LOCAL_LOOSEBOARD".equals(order.sourceBusinessType()) || order.sourceBusinessNo()==null)continue;
for(var key:order.sourceKeys())looseboardOrders.put(List.of(order.customerId(),key),order.sourceBusinessNo());
}
return new Detail(row,original,cargoDetails(cargo,looseboardOrders),projection.photos(repository.record(source.getId())));
}
/** 商业订单由排车公开契约确认,普通货物保持原行,不能按编号前缀猜测散板。 */
private List<Cargo> cargoDetails(List<CargoRow> cargo,Map<List<String>,String> looseboardOrders) {
var groups=new LinkedHashMap<List<String>,List<CargoRow>>();
for(var item:cargo) {
var order=looseboardOrders.get(List.of(item.getCustomerId(),item.getSourceKey()));
var key=order==null?List.of("SOURCE",item.getCustomerId(),item.getSourceKey()):List.of("LOOSEBOARD",item.getCustomerId(),order);
groups.computeIfAbsent(key,ignored->new ArrayList<>()).add(item);
}
return groups.entrySet().stream().map(entry->{
var rows=entry.getValue();var first=rows.get(0);boolean looseboard="LOOSEBOARD".equals(entry.getKey().get(0));
var destinations=rows.stream().map(projection::destinationKey).distinct().toList();
var pallets=rows.stream().map(CargoRow::getPalletNo).filter(Objects::nonNull).distinct().toList();
// 同一来源只贡献一次箱数,实托码展示完整集合,不复制整托箱数。
var sources=rows.stream().collect(Collectors.toMap(CargoRow::getSourceKey,c->c,(a,b)->a,LinkedHashMap::new)).values();
String packages=sources.stream().anyMatch(c->c.getPackageCount()==null)?null:Long.toString(sources.stream().mapToLong(CargoRow::getPackageCount).sum());
return new Cargo(first.getSourceKey(),first.getCustomerId(),first.getCustomerName(),looseboard?entry.getKey().get(2):first.getOrderNo(),
looseboard?null:first.getInboundCode(),pallets.isEmpty()?null:String.join("、",pallets),destinations.get(0),packages,destinations);
}).toList();
}
/** 缺少历史归属必须明确拒绝,不能按当前车辆主档降级猜测。 */
public Source requireOwned(String number) {
@@ -25,7 +25,17 @@ public interface TripOperationsQueryMapper {
(SELECT d.package_count FROM ll_dispatch_order d WHERE d.trip_id=t.id AND d.task_type='LOCAL_TRUCK' LIMIT 1)
ELSE (SELECT SUM(p.actual_packages) FROM ll_fleet_pickup_task p
WHERE CAST(p.trip_no AS BINARY(512))=CAST(t.trip_no AS BINARY(512)) AND p.status IN ('PICKED','ARRIVED')) END)
ELSE NULL END AS transport_facts
ELSE (SELECT JSON_OBJECT('taskType','PHOTO_LOOSEBOARD','packages',
JSON_EXTRACT(dh.snapshot_json,'$.shippedPackageCount'))
FROM ll_dispatch_order d JOIN ll_dispatch_history dh ON dh.dispatch_id=d.id
WHERE d.trip_id=t.id AND dh.action='LOOSEBOARD_DELIVERING'
AND JSON_EXTRACT(dh.snapshot_json,'$.departedAt') IS NOT NULL
AND JSON_UNQUOTE(JSON_EXTRACT(dh.snapshot_json,'$.departedAt'))!='null'
AND (JSON_EXTRACT(dh.snapshot_json,'$.loadingCompletedAt') IS NULL
OR JSON_UNQUOTE(JSON_EXTRACT(dh.snapshot_json,'$.loadingCompletedAt'))='null')
AND JSON_EXTRACT(dh.snapshot_json,'$.pickedPackageCount')=0
AND JSON_EXTRACT(dh.snapshot_json,'$.shippedPackageCount') &gt; 0
ORDER BY dh.record_version,dh.id LIMIT 1) END AS transport_facts
FROM ll_fleet_trip t
WHERE t.departed_at &gt;= #{q.from} AND t.departed_at &lt; #{q.to}
<if test='q.warehouseId != null'>AND t.warehouse_id=#{q.warehouseId}</if>
@@ -55,6 +55,10 @@ public class TripOperationsService {
var trip=snapshot.trip(); var load=snapshot.confirmedLoad();
if(trip!=null && Set.of("LOCAL_TRUCK","PICKUP").contains(Objects.toString(trip.tripPurpose(),"")))
return transport(fact,trip);
// 纯散板照片发运没有仓内实载,只接受查询取得的不可变照片发运依据。
if(trip!=null && load==null && fact.transportFacts()!=null
&& "PHOTO_LOOSEBOARD".equals(json.readTree(fact.transportFacts()).path("taskType").asText()))
return transport(fact,trip);
if (trip==null || !fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || load==null || load.cargo()==null || load.pallets()==null || load.cargo().isEmpty() || load.pallets().isEmpty())
throw new IllegalStateException("发车历史缺少装载或实际时间,请核对车次"+fact.tripNo());
var externalByPallet=new HashMap<String,Long>(); var money=new TreeMap<String,BigDecimal>(); var bookingNumbers=new HashSet<String>();
@@ -111,9 +115,11 @@ public class TripOperationsService {
if(!fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || trip.stops()==null || trip.stops().size()<2)
throw new IllegalStateException("运输发车历史缺少行程或实际时间");
var facts=fact.transportFacts()==null?null:json.readTree(fact.transportFacts());
if(facts==null || !trip.tripPurpose().equals(facts.path("taskType").asText()))throw new IllegalStateException("运输出车缺少来源依据");
boolean photo=facts!=null && "PHOTO_LOOSEBOARD".equals(facts.path("taskType").asText());
if(facts==null || (!photo && !Objects.equals(trip.tripPurpose(),facts.path("taskType").asText())))throw new IllegalStateException("运输出车缺少来源依据");
// 箱数统计仅累计已登记实提,未登记不计入,不能用预报补出运量。
var packages=facts.get("packages");long quantity=packages==null || packages.isNull()?0:packages.longValue();
if(photo && (packages==null || !packages.isIntegralNumber() || quantity<=0))throw new IllegalStateException("散板照片发运缺少实际箱数");
if(quantity<0)throw new IllegalStateException("运输箱数不合法");
var destinations=new ArrayList<TripOperationsModels.Destination>();
for(int i=1;i<trip.stops().size();i++) {
@@ -44,7 +44,7 @@ public interface InboundPlanOrderMapper {
"COALESCE(NULLIF(o.transfer_warehouse_code,''),(SELECT MIN(NULLIF(d.destination_warehouse_code,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseCode," +
"COALESCE(NULLIF(o.transfer_warehouse_name,''),(SELECT MIN(NULLIF(d.destination_warehouse_name,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseName," +
"CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 ELSE " +
"(SELECT COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) FROM ll_order_detail d WHERE d.order_id=o.id) END AS destinationCount," +
"(SELECT COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),NULLIF(TRIM(d.destination_warehouse_name),''),'#EMPTY#')) FROM ll_order_detail d WHERE d.order_id=o.id) END AS destinationCount," +
"o.detained AS detained,o.version AS version,p.plan_no AS planNo,p.status AS planStatus," +
"p.pallet_count AS plannedPalletCount," +
"(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " +
@@ -93,7 +93,7 @@ public interface InboundPlanOrderMapper {
"COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," +
"COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," +
"CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 " +
"ELSE COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) END AS destinationCount," +
"ELSE COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),NULLIF(TRIM(d.destination_warehouse_name),''),'#EMPTY#')) END AS destinationCount," +
"0 AS destinationMismatchCount,o.detained AS detained,o.version AS version " +
"FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " +
"WHERE o.customer_id=#{customerId} AND o.id=#{orderId} " +
@@ -15,7 +15,7 @@ public interface InboundPlanQueryMapper {
String SCHEDULED_DISPATCH = "EXISTS (SELECT 1 FROM ll_dispatch_item di" +
" JOIN ll_dispatch_order dispatch ON dispatch.id=di.dispatch_order_id" +
" WHERE di.customer_id=p.customer_id AND di.order_id=l.order_id" +
" AND dispatch.status&lt;&gt;'CANCELLED')";
" AND dispatch.status&lt;&gt;'CANCELLED' AND COALESCE(dispatch.task_type,'DELIVERY') NOT IN ('PICKUP','LOCAL_TRUCK'))";
String FROM_AND_FILTER = " FROM ll_inbound_plan_line l" +
" JOIN ll_inbound_plan p ON p.id=l.plan_id" +
@@ -956,28 +956,35 @@ public class InboundPdaWorkflowService {
}
private boolean sameWarehouse(InboundPlanPallet left, InboundPlanPallet right) {
String leftKey = left.getDestinationWarehouseId() != null
? left.getDestinationWarehouseId() : left.getDestinationWarehouseCode();
String rightKey = right.getDestinationWarehouseId() != null
? right.getDestinationWarehouseId() : right.getDestinationWarehouseCode();
String leftKey = destinationKey(left.getDestinationWarehouseId(), left.getDestinationWarehouseCode(),
left.getDestinationWarehouseName());
String rightKey = destinationKey(right.getDestinationWarehouseId(), right.getDestinationWarehouseCode(),
right.getDestinationWarehouseName());
return leftKey != null && leftKey.equals(rightKey);
}
private boolean sameWarehouse(InboundPlanCarton carton, InboundPlanPallet pallet) {
String cartonKey = carton.getDestinationWarehouseId() != null
? carton.getDestinationWarehouseId() : carton.getDestinationWarehouseCode();
String palletKey = pallet.getDestinationWarehouseId() != null
? pallet.getDestinationWarehouseId() : pallet.getDestinationWarehouseCode();
String cartonKey = destinationKey(carton.getDestinationWarehouseId(), carton.getDestinationWarehouseCode(),
carton.getDestinationWarehouseName());
String palletKey = destinationKey(pallet.getDestinationWarehouseId(), pallet.getDestinationWarehouseCode(),
pallet.getDestinationWarehouseName());
return cartonKey != null && cartonKey.equals(palletKey);
}
private String destinationKey(InboundPlanPallet pallet) {
String value = pallet.getDestinationWarehouseId() != null
? pallet.getDestinationWarehouseId() : pallet.getDestinationWarehouseCode();
String value = destinationKey(pallet.getDestinationWarehouseId(), pallet.getDestinationWarehouseCode(),
pallet.getDestinationWarehouseName());
if (value == null || value.isBlank()) throw conflict("Planned pallet destination warehouse is empty");
return value;
}
private String destinationKey(String warehouseId, String warehouseCode, String privateAddressName) {
if (warehouseId != null) return warehouseId;
if (warehouseCode != null) return warehouseCode;
if (privateAddressName == null || privateAddressName.isBlank()) return null;
return "PRIVATE:" + privateAddressName.trim();
}
private void validateDestinationWarehouse(InboundPlanPallet pallet, long warehouseId) {
String destinationId = pallet.getDestinationWarehouseId();
if (destinationId == null || destinationId.isBlank()) return;
@@ -273,7 +273,8 @@ public class InboundStockReceiptService {
throw conflict("Stock-in requires picked packages and an assigned warehouse pallet");
}
String destinationCode = first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode());
if (destinationCode == null && trim(line.getDestinationWarehouseId()) == null) {
if (destinationCode == null && trim(line.getDestinationWarehouseId()) == null
&& (!"OVERSEAS".equals(line.getDestinationType()) || trim(line.getDestinationWarehouseName()) == null)) {
throw conflict("Destination warehouse ID or code is required for stock-in");
}
if (destinationCode == null) destinationCode = "";
@@ -291,7 +291,8 @@ public class InboundPlanService {
boolean fba = isFbaDestination(row);
if (fba ? trimToNull(row.fbaWarehouseCode()) == null
: row.destinationCount() != 1 || trimToNull(row.destinationWarehouseId()) == null
&& trimToNull(row.destinationWarehouseCode()) == null) blockers.add("DESTINATION_NOT_READY");
&& trimToNull(row.destinationWarehouseCode()) == null
&& trimToNull(row.destinationWarehouseName()) == null) blockers.add("DESTINATION_NOT_READY");
if (row.packageCount() < 0 || row.volumeCbm() == null || row.volumeCbm().signum() < 0
|| row.packageCount() > 0 && row.volumeCbm().signum() == 0) blockers.add("CARGO_FACT_INVALID");
}
@@ -1181,7 +1182,13 @@ public class InboundPlanService {
private String warehouseKey(SourceOrderSnapshot order) {
String id = trimToNull(order.destinationWarehouseId());
String code = trimToNull(order.destinationWarehouseCode());
return id != null ? "ID:" + id : code == null ? null : "CODE:" + code;
if (id != null) return "ID:" + id;
if (code != null) return "CODE:" + code;
if (trimToNull(order.fbaWarehouseCode()) == null
&& trimToNull(order.destinationWarehouseName()) != null) {
return "PRIVATE:" + order.orderId();
}
return null;
}
private static String trimToNull(String value) {
@@ -44,11 +44,12 @@ public class MerchantLocalLooseboardController {
return ApiResponse.success(service.page(customer(user), query));
}
/** 客户详情使用与列表一致的运输进度。 */
@GetMapping("/{orderNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_LIST + "')")
public ApiResponse<LocalLooseboardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String orderNo) {
return ApiResponse.success(service.detail(customer(user), orderNo));
return ApiResponse.success(service.merchantDetail(customer(user), orderNo));
}
@PutMapping("/{orderNo}")
@@ -22,7 +22,8 @@ public final class LocalLooseboardResponses {
String operationType, String fromStatus, String toStatus, String operatorId,
String idempotencyKey, String changeDetail, String remark, LocalDateTime createdAt) { }
/** 详情独立返回全部有效排车,同车多托合并为一个单号。 */
public record Detail(
Summary summary, String originAddress, String destinationAddress, String remark,
String createdBy, List<History> histories) { }
String createdBy, List<History> histories, List<String> dispatchNos) { }
}
@@ -8,6 +8,40 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface LocalLooseboardOrderMapper extends BaseMapper<LocalLooseboardOrder> {
// 客户状态按正式排车与取货事实投影;不改变收货、测量等内部状态门禁。
String DELIVERY = " FROM ll_inbound_plan p"
+ " JOIN ll_inbound_plan_pallet pp ON pp.plan_id=p.id AND pp.customer_id=p.customer_id"
+ " JOIN ll_dispatch_item i ON i.plan_pallet_id=pp.id AND i.customer_id=p.customer_id"
+ " JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id AND d.status='ACTIVE'"
+ " WHERE p.source_order_id=ll_local_looseboard_order.lastleg_order_id"
+ " AND p.customer_id=ll_local_looseboard_order.customer_id";
String PICKUP = " FROM ll_warehouse_transfer_order w"
+ " JOIN ll_fleet_pickup_task t ON t.transfer_order_id=w.id AND t.customer_id=w.customer_id"
+ " JOIN ll_fleet_trip f ON f.trip_no=t.trip_no AND f.status IN ('PLANNED','IN_TRANSIT')"
+ " WHERE w.customer_id=ll_local_looseboard_order.customer_id"
+ " AND w.transfer_no=ll_local_looseboard_order.transfer_no";
String MERCHANT_STATUS = "(CASE"
+ " WHEN ll_local_looseboard_order.status IN ('FUNDING_PENDING','FUNDING_FAILED','PENDING_ACCEPTANCE','COMPLETED','CANCELLED')"
+ " THEN ll_local_looseboard_order.status"
+ " WHEN ll_local_looseboard_order.status='IN_TRANSIT' OR EXISTS (SELECT 1" + DELIVERY
+ " AND d.stage IN ('DELIVERING','DELIVERED')) THEN 'IN_TRANSIT'"
+ " WHEN EXISTS (SELECT 1" + PICKUP
+ " AND (t.status='PICKED' OR (t.status='ASSIGNED' AND f.status='IN_TRANSIT'))) THEN 'PICKING_UP'"
+ " WHEN EXISTS (SELECT 1" + DELIVERY + ") OR EXISTS (SELECT 1" + PICKUP
+ " AND t.status='ASSIGNED') THEN 'DISPATCHED'"
+ " WHEN ll_local_looseboard_order.status='DISPATCHED' THEN 'DISPATCHED'"
+ " ELSE 'PENDING_DISPATCH' END)";
/** 批量读取同客户散板的运输状态,与分页筛选使用同一表达式。 */
@Select("<script>SELECT id," + MERCHANT_STATUS + " AS status FROM ll_local_looseboard_order"
+ " WHERE customer_id=#{customerId} AND id IN"
+ " <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
java.util.List<MerchantStatus> merchantStatuses(@Param("customerId") String customerId,
@Param("ids") java.util.List<Long> ids);
/** 客户列表与详情共享的只读状态。 */
record MerchantStatus(Long id, String status) { }
@Select("SELECT COUNT(*) FROM ll_local_looseboard_order WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} AND lastleg_order_id IS NOT NULL")
int linkedReceiving(@Param("customerId") String customerId,@Param("transferNo") String transferNo);
@org.apache.ibatis.annotations.Update("UPDATE ll_local_looseboard_order SET status='PENDING_DISPATCH',updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} AND status='WAITING_INBOUND'")
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.looseboard.port;
import java.util.List;
/** 散板原单的排车关联只读入口,不向客户暴露整车货物。 */
public interface LooseboardDispatches {
/** 按真实客户和散板原单返回去重后的有效排车单号。 */
List<String> looseboardDispatches(String customerId, String orderNo);
}
@@ -33,18 +33,35 @@ public class LocalLooseboardRepository {
public LocalLooseboardOrder lockNo(String customer, String number) { return orders.lockNo(customer, number); }
/** 在客户范围内组合路线、状态与取货日期筛选并返回分页需求。 */
public Page<LocalLooseboardOrder> page(LocalLooseboardRequests.Query query) {
return orders.selectPage(Page.of(query.page(), query.size()), queryWrapper(query, false));
}
/** 客户按运输状态筛选后分页,确保总数和返回记录使用相同口径。 */
public Page<LocalLooseboardOrder> merchantPage(LocalLooseboardRequests.Query query) {
return orders.selectPage(Page.of(query.page(), query.size()), queryWrapper(query, true));
}
/** 按认证客户批量装配状态,空页不查询排车事实。 */
public java.util.Map<Long, String> merchantStatuses(String customer, List<Long> ids) {
if (ids.isEmpty()) return java.util.Map.of();
return orders.merchantStatuses(customer, ids).stream().collect(java.util.stream.Collectors.toMap(
LocalLooseboardOrderMapper.MerchantStatus::id, LocalLooseboardOrderMapper.MerchantStatus::status));
}
/** 路线与时间筛选共用,仅客户查询采用实际运输进度。 */
private LambdaQueryWrapper<LocalLooseboardOrder> queryWrapper(LocalLooseboardRequests.Query query, boolean merchant) {
LambdaQueryWrapper<LocalLooseboardOrder> wrapper = new LambdaQueryWrapper<LocalLooseboardOrder>()
.eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId()))
.like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo()))
.and(StringUtils.hasText(query.routeKeyword()), value -> value
.like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword()))
.or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword())))
.eq(StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus,
.eq(!merchant && StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus,
StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null)
.ge(query.requestedFrom() != null, LocalLooseboardOrder::getPickupAt, query.requestedFrom() == null ? null : query.requestedFrom().atStartOfDay())
.lt(query.requestedTo() != null, LocalLooseboardOrder::getPickupAt, query.requestedTo() == null ? null : query.requestedTo().plusDays(1).atStartOfDay())
.orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId);
return orders.selectPage(Page.of(query.page(), query.size()), wrapper);
if (merchant && StringUtils.hasText(query.status())) {
wrapper.apply(LocalLooseboardOrderMapper.MERCHANT_STATUS + "={0}", query.status().trim().toUpperCase(java.util.Locale.ROOT));
}
return wrapper;
}
/** 规范查询文字,缺失值不参与筛选。 */
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
@@ -48,26 +48,43 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
history(order,"TRANSPORT_PHOTO",from,next,operatorId,null,null,"按排车取货/送达照片更新,累计送达"+deliveredPackages+"箱",now);
}
@Resource private com.baiyue.lastleg.looseboard.port.LooseboardReceiving receiving;
@Resource private com.baiyue.lastleg.looseboard.port.LooseboardDispatches dispatches;
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPool;
public static final String PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE";
public static final String FUNDING_PENDING = "FUNDING_PENDING";
public static final String FUNDING_FAILED = "FUNDING_FAILED";
public static final String DISPATCHED = "DISPATCHED";
public static final String IN_TRANSIT = "IN_TRANSIT";
public static final String COMPLETED = "COMPLETED";
public static final String CANCELLED = "CANCELLED";
private static final Set<String> STATUSES = Set.of(PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED, "WAITING_INBOUND", "RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH");
private static final Set<String> STATUSES = Set.of(FUNDING_PENDING, FUNDING_FAILED, PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED, "WAITING_INBOUND", "RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH");
private static final Set<String> MERCHANT_STATUSES = Set.of(FUNDING_PENDING, FUNDING_FAILED, PENDING_ACCEPTANCE,
"PENDING_DISPATCH", DISPATCHED, "PICKING_UP", IN_TRANSIT, COMPLETED, CANCELLED);
private static final Map<String, Set<String>> TRANSITIONS = Map.of(
PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED),
FUNDING_PENDING, Set.of(CANCELLED),
FUNDING_FAILED, Set.of(CANCELLED),
DISPATCHED, Set.of(IN_TRANSIT, CANCELLED),
IN_TRANSIT, Set.of(COMPLETED));
@Resource private LocalLooseboardRepository repository;
@Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator;
@Resource private ObjectMapper objectMapper;
@Resource private com.baiyue.lastleg.wallet.budget.OrderBudgetService orderBudget;
/** 在同一事务保存客户需求及创建历史,等待需求池匹配后登记真实来货,创建键重复时核对请求哈希。 */
/** 平台代客和客户自助下单共用预算冻结,冻结成功后才进入待审核。 */
@Transactional
public LocalLooseboardResponses.Detail create(String customerId, String operatorId,
LocalLooseboardRequests.Create request) {
var result = orderBudget.submit(customerId, "looseboard", request.idempotencyKey().trim(), request.budgetAmount(),
StringUtils.hasText(request.currency()) ? norm(request.currency()) : "USD",
() -> createPending(customerId, operatorId, request), value -> value.summary().orderNo());
return detail(customerId, result.summary().orderNo());
}
/** 业务单与冻结指令同事务保存,任何创建失败均不留下独立预占。 */
private LocalLooseboardResponses.Detail createPending(String customerId, String operatorId,
LocalLooseboardRequests.Create request) {
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
LocalLooseboardOrder replay = repository.findKey(customerId, key);
@@ -81,7 +98,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(),
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(),
request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark());
order.setStatus(PENDING_ACCEPTANCE);
order.setStatus(FUNDING_PENDING);
order.setIdempotencyKey(key);
order.setRequestHash(requestHash);
order.setCreatedBy(operatorId);
@@ -96,7 +113,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
if (concurrent != null) return replay(customerId, concurrent, requestHash);
throw exception;
}
history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId,
history(order, "CREATE", null, FUNDING_PENDING, operatorId,
key, null, request.remark(), now);
return detail(customerId, order.getOrderNo());
@@ -112,13 +129,28 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
result.getSize(), result.getTotal(), result.getPages());
}
/** 客户查询强制使用认证客户范围,沿同一需求筛选合同分页。 */
/** 客户查询使用认证范围,筛选、分页及显示统一按真实运输进度。 */
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<LocalLooseboardResponses.Summary> page(String customerId,
LocalLooseboardRequests.MerchantQuery query) {
return page(new LocalLooseboardRequests.Query(query.page(), query.size(), customerId,
query.orderNo(), query.routeKeyword(), query.status(),
query.requestedFrom(), query.requestedTo()));
var scoped = new LocalLooseboardRequests.Query(query.page(), query.size(), customerId,
query.orderNo(), query.routeKeyword(), query.status(), query.requestedFrom(), query.requestedTo());
validateQuery(scoped, MERCHANT_STATUSES);
var result = repository.merchantPage(scoped);
var statuses = repository.merchantStatuses(customerId, result.getRecords().stream().map(LocalLooseboardOrder::getId).toList());
var arrangements = fleetPool.arrangements(result.getRecords().stream().map(this::poolKey).toList());
return new PageResult<>(result.getRecords().stream()
.map(o -> summary(o, arrangements.get(poolKey(o)), Objects.requireNonNull(statuses.get(o.getId())))).toList(),
result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
}
/** 客户详情与列表共用运输状态投影,不改写作业用原始状态。 */
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public LocalLooseboardResponses.Detail merchantDetail(String customerId, String orderNo) {
var order = repository.findNo(customerId, orderNo);
if (order == null) throw notFound();
var status = repository.merchantStatuses(customerId, java.util.List.of(order.getId())).get(order.getId());
return response(order, Objects.requireNonNull(status));
}
/** 按客户与业务单号读取需求及历史。 */
@@ -133,6 +165,14 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
@Transactional
public LocalLooseboardResponses.Detail update(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.Update request) {
return orderBudget.update(customerId, "looseboard", orderNo, request.budgetAmount(),
StringUtils.hasText(request.currency()) ? norm(request.currency()) : "USD",
() -> updateLocked(customerId, orderNo, operatorId, request));
}
/** 已冻结预算不允许通过普通编辑改价,业务字段仍按订单版本校验。 */
private LocalLooseboardResponses.Detail updateLocked(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.Update request) {
LocalLooseboardOrder order = lock(customerId, orderNo);
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
if (!PENDING_ACCEPTANCE.equals(order.getStatus())) {
@@ -157,6 +197,13 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
@Transactional
public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.ChangeStatus request) {
return orderBudget.withOrder(customerId, "looseboard", orderNo,
() -> changeStatusLocked(customerId, orderNo, operatorId, request));
}
/** 取消与释放意图同事务,冻结在途回执到达后也会自动释放。 */
private LocalLooseboardResponses.Detail changeStatusLocked(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.ChangeStatus request) {
LocalLooseboardOrder order = lock(customerId, orderNo);
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
@@ -181,6 +228,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
touch(order, now);
repository.updateOrder(order);
syncLastlegOrder(order, next, now);
if (CANCELLED.equals(next)) orderBudget.releaseAfterReview(customerId, "looseboard", orderNo);
LocalLooseboardOperation operation = new LocalLooseboardOperation();
operation.setId(IdWorker.getId()); operation.setOrderId(order.getId());
operation.setCustomerId(customerId); operation.setOperationType("STATUS");
@@ -195,6 +243,13 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
@Transactional
public LocalLooseboardResponses.Detail review(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.Review request) {
return orderBudget.withOrder(customerId, "looseboard", orderNo,
() -> reviewLocked(customerId, orderNo, operatorId, request));
}
/** 冻结与审核串行核对,通过后只释放预算,实际费用继续走原费用链。 */
private LocalLooseboardResponses.Detail reviewLocked(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.Review request) {
LocalLooseboardOrder order = lock(customerId, orderNo);
String requestHash = hash(request);
var previous = repository.findOperation(customerId, order.getId(), request.idempotencyKey());
@@ -204,16 +259,19 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
}
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
String decision = request.decision().trim().toUpperCase();
if (!PENDING_ACCEPTANCE.equals(order.getStatus())) throw conflict("散板订单已审核,请刷新后重试");
if (!PENDING_ACCEPTANCE.equals(order.getStatus())) throw conflict("散板订单须资金冻结成功且待审核,请刷新后重试");
if ("REJECT".equals(decision)) {
if (!StringUtils.hasText(request.remark())) throw badRequest("驳回须填写原因");
return changeStatus(customerId, orderNo, operatorId, new LocalLooseboardRequests.ChangeStatus(order.getVersion(), "CANCELLED", request.idempotencyKey(), request.remark()));
order.setStatus(CANCELLED);
} else {
if (!"APPROVE".equals(decision)) throw badRequest("审核结果无效");
if (!orderBudget.readOrder(customerId, "looseboard", orderNo).usable()) throw conflict("散板订单资金尚未冻结成功,暂不能审核");
if (order.getOperatingWarehouseId()==null || order.getDeliveryMode()==null) throw conflict("散板订单缺少收货安排,请先编辑补齐");
var created = receiving.approve(order, operatorId);
order.setTransferNo(created.transferNo()); order.setLastlegOrderId(created.lastlegOrderId()); order.setStatus("WAITING_INBOUND");
}
if (!"APPROVE".equals(decision)) throw badRequest("审核结果无效");
if (order.getOperatingWarehouseId()==null || order.getDeliveryMode()==null) throw conflict("旧散板订单缺少收货安排,请先编辑补齐");
var created = receiving.approve(order, operatorId);
order.setTransferNo(created.transferNo()); order.setLastlegOrderId(created.lastlegOrderId()); order.setStatus("WAITING_INBOUND");
touch(order, LocalDateTime.now()); repository.updateOrder(order);
orderBudget.releaseAfterReview(customerId, "looseboard", orderNo);
history(order, "REVIEW", PENDING_ACCEPTANCE, order.getStatus(), operatorId, request.idempotencyKey(), null, request.remark(), LocalDateTime.now());
var operation=new LocalLooseboardOperation(); operation.setId(IdWorker.getId()); operation.setOrderId(order.getId()); operation.setCustomerId(customerId);
operation.setOperationType("REVIEW"); operation.setIdempotencyKey(request.idempotencyKey()); operation.setRequestHash(requestHash); operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId); operation.setCreatedAt(LocalDateTime.now()); repository.insertOperation(operation);
@@ -278,15 +336,20 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
return order;
}
/** 装配需求详情、真实承运安排与不可变历史。 */
/** 装配需求详情、真实承运安排、全部有效排车与不可变历史。 */
private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order) {
return new LocalLooseboardResponses.Detail(summary(order), order.getOriginAddress(),
return response(order, order.getStatus());
}
/** 展示状态仅用于响应,原单及历史保持真实作业状态。 */
private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order, String status) {
return new LocalLooseboardResponses.Detail(summary(order, fleetPool.arrangement(poolKey(order)), status), order.getOriginAddress(),
order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(),
repository.histories(order.getCustomerId(), order.getId()).stream()
.map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(),
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
value.getChangeDetail(), value.getRemark(),
value.getCreatedAt())).toList());
value.getCreatedAt())).toList(), dispatches.looseboardDispatches(order.getCustomerId(), order.getOrderNo()));
}
/** 构造同客户商业订单的承运查询身份。 */
@@ -294,16 +357,18 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
return new com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey(order.getCustomerId(), "LOCAL_LOOSEBOARD", order.getId());
}
/** 装配原值需求摘要,预计托数与实际履约保持独立。 */
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order) {
return summary(order, fleetPool.arrangement(poolKey(order)));
}
/** 装配原值需求摘要,预计托数与实际履约保持独立。 */
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) {
return summary(order, arrangement, order.getStatus());
}
/** 按调用方所需口径装配状态,其余原单与承运事实保持一致。 */
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order,
com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement, String status) {
return new LocalLooseboardResponses.Summary(order.getId(), order.getCustomerId(), order.getOrderNo(),
order.getPalletRequired(), order.getDeliveryMode(), order.getOperatingWarehouseId(), order.getDestinationType(), order.getDestinationPostalCode(), order.getTransferNo(), order.getDeliveryContactName(), order.getDeliveryContactPhone(), order.getEstimatedArrivalAt(), order.getTrackingNo(), order.getTransportType(), order.getOrigin(),
order.getDestination(), order.getProductName(), order.getEstimatedPalletCount(), Objects.requireNonNullElse(order.getPackageCount(), 0),
order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(),
order.getOriginContactName(), order.getOriginContactPhone(), order.getDestinationContactName(), order.getDestinationContactPhone(), order.getPickupAt(), order.getDeliveryAt(), order.getStatus(),
order.getOriginContactName(), order.getOriginContactPhone(), order.getDestinationContactName(), order.getDestinationContactPhone(), order.getPickupAt(), order.getDeliveryAt(), status,
value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt(), arrangement);
}
@@ -382,7 +447,12 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
/** 校验状态与取货日期筛选边界。 */
private void validateQuery(LocalLooseboardRequests.Query query) {
if (StringUtils.hasText(query.status()) && !STATUSES.contains(norm(query.status()))) {
validateQuery(query, STATUSES);
}
/** 分别限制客户可见运输状态和平台内部作业状态,并校验时间范围。 */
private void validateQuery(LocalLooseboardRequests.Query query, Set<String> statuses) {
if (StringUtils.hasText(query.status()) && !statuses.contains(norm(query.status()))) {
throw badRequest("Unsupported looseboard order status");
}
if (query.requestedFrom() != null && query.requestedTo() != null
@@ -51,14 +51,15 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
String externalBookingNo, boolean poolSource) { }
// Manual node snapshots replace container nodes permanently for the order.
String SAILING_STAGE_FILTER_SQL = "(ll_order.order_type='FIRST_LEG' AND " +
String SAILING_STAGE_VALUE_SQL = "(ll_order.order_type='FIRST_LEG' AND " +
"(CASE WHEN COALESCE(ll_order.manual_node_override,FALSE)=TRUE THEN " +
"CASE WHEN EXISTS (SELECT 1 FROM ll_order_manual_node n WHERE n.order_id=ll_order.id " +
"AND n.customer_id=ll_order.customer_id AND n.node_code='ACTUAL_DEPARTURE' AND n.node_at IS NOT NULL) " +
"THEN 1 ELSE 0 END ELSE " +
"CASE WHEN EXISTS (SELECT 1 FROM ll_container_shipping_gate g " +
"WHERE g.container_booking_id=ll_order.source_container_booking_id " +
"AND g.node_sequence>=40 AND g.actual_at IS NOT NULL) THEN 1 ELSE 0 END END)={0})";
"AND g.node_sequence>=40 AND g.actual_at IS NOT NULL) THEN 1 ELSE 0 END END)";
String SAILING_STAGE_FILTER_SQL = SAILING_STAGE_VALUE_SQL + "={0})";
@Select("<script>SELECT r.lastleg_order_id,r.relabel_no,r.status,r.notified_package_count AS quantity, " +
"CASE WHEN NOT EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=r.id AND ri.operation_type&lt;&gt;'CARTON_LABEL') THEN '箱' ELSE '件' END AS quantity_unit,r.created_at " +
@@ -71,11 +72,18 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
@Select("<script>SELECT o.id AS orderId,CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di " +
"JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id " +
"WHERE di.customer_id=o.customer_id AND di.order_id=o.id AND d.status!='CANCELLED') " +
"WHERE di.customer_id=o.customer_id AND di.order_id=o.id AND d.status!='CANCELLED' AND COALESCE(d.task_type,'DELIVERY') NOT IN ('PICKUP','LOCAL_TRUCK')) " +
"THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END AS dispatchStatus FROM ll_order o WHERE o.id IN " +
"<foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<DispatchStatusRow> findDispatchStatuses(@Param("orderIds") List<Long> orderIds);
/** 已开船来源键用于聚合前去重,保持客户隔离和人工节点覆盖规则。 */
@Select("<script>SELECT DISTINCT customer_id,source_order_no FROM ll_order WHERE source_order_no IS NOT NULL " +
"<if test='customer != null'>AND customer_id=#{customer} </if>AND " +
SAILING_STAGE_VALUE_SQL + "=1)" + "</script>")
List<DepartedSource> departedSources(@Param("customer") String customer);
record DepartedSource(String customerId, String sourceOrderNo) { }
record DispatchStatusRow(Long orderId, String dispatchStatus) { }
record OrderWeightRow(Long orderId, BigDecimal grossWeightKg, BigDecimal netWeightKg) { }
@@ -12,6 +12,10 @@ import lombok.RequiredArgsConstructor;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import java.time.LocalDateTime;
import org.springframework.stereotype.Repository;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
/** 资金门禁只访问本服务采购、卡车原单及其状态历史。 */
@Repository
@@ -21,6 +25,21 @@ public class OrderFundingRepository {
private final OverseasProcurementStatusHistoryMapper purchaseHistory;
private final UsTruckOrderMapper trucks;
private final UsTruckOrderStatusHistoryMapper truckHistory;
private final LocalLooseboardOrderMapper looseboards;
private final LocalLooseboardHistoryMapper looseboardHistory;
/** 预算锁后按认证客户锁定散板原单。 */
public LocalLooseboardOrder looseboard(String customer, String number) { return looseboards.lockNo(customer, number); }
/** 保存散板资金状态,不改客户填写的业务资料。 */
public void save(LocalLooseboardOrder order) { looseboards.updateById(order); }
/** 追加散板冻结结果和重试的不可变历史。 */
public void history(LocalLooseboardOrder order, String from, String target, String reason, LocalDateTime now) {
var history = new LocalLooseboardHistory();
history.setId(IdWorker.getId()); history.setOrderId(order.getId()); history.setCustomerId(order.getCustomerId());
history.setOperationType("FUNDING"); history.setFromStatus(from); history.setToStatus(target);
history.setOperatorId("SYSTEM"); history.setRemark(reason); history.setCreatedAt(now);
looseboardHistory.insert(history);
}
/** 预算锁后按客户锁定采购原单,统一先预算后订单的加锁顺序。 */
public OverseasProcurementOrder purchase(String customer, String number) { return purchases.lockNo(customer, number); }
@@ -8,7 +8,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Set;
/** 两种订单复用资金状态门禁,不反向依赖订单编排服务。 */
/** 采购、卡车和散板复用资金状态门禁,不反向依赖订单编排服务。 */
@Service
@RequiredArgsConstructor
public class OrderFundingService implements OrderFundingPort {
@@ -36,6 +36,15 @@ public class OrderFundingService implements OrderFundingPort {
order.setStatus(target); order.setVersion(order.getVersion() + 1); order.setUpdatedAt(now);
repository.save(order);
repository.history(order, from, target, reason, now);
} else if ("looseboard".equals(budget.getKind())) {
var order = repository.looseboard(budget.getCustomerId(), budget.getOrderNo());
if (order == null) throw new IllegalStateException("资金关联的散板订单不存在");
String from = order.getStatus();
if (!changeable(from, target)) return;
String next = "PENDING_REVIEW".equals(target) ? "PENDING_ACCEPTANCE" : target;
order.setStatus(next); order.setVersion(order.getVersion() + 1); order.setUpdatedAt(now);
repository.save(order);
repository.history(order, from, next, reason, now);
}
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.repository;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.springframework.stereotype.Repository;
import java.util.Set;
import java.util.stream.Collectors;
/** 尾程实际船节点优先于头程摘要中缺失的开船时间。 */
@Repository
public class PreDepartureOrderRepository {
private final LastlegOrderMapper mapper;
/** 仅读本服务尾程事实,不读取订单服务数据库。 */
public PreDepartureOrderRepository(LastlegOrderMapper mapper) { this.mapper = mapper; }
/** 按客户与来源订单组成去重键,在分页前排除已开船记录。 */
public Set<String> departedKeys(String customer) {
return mapper.departedSources(customer).stream()
.map(row -> row.customerId() + ":" + row.sourceOrderNo()).collect(Collectors.toSet());
}
}
@@ -93,6 +93,7 @@ public class LastlegOrderService {
@Resource private ReturnOrderReferenceResolver returnOrderReferences;
@Resource private com.baiyue.lastleg.transfer.service.TransferQuantityService transferQuantities;
@Resource private OrderPreDepartureClient orderPreDepartureClient;
@Resource private com.baiyue.lastleg.repository.PreDepartureOrderRepository preDepartureOrders;
@Value("${baiyue.security.internal-service-token:}") private String internalServiceToken;
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
@@ -101,12 +102,7 @@ public class LastlegOrderService {
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query, String relabelNo) {
if (query.sailingStage() == LastlegOrderRequests.SailingStage.BEFORE_DEPARTURE) {
Long customer = parseLong(customerId);
var response = orderPreDepartureClient.page(internalServiceToken, customer, query.orderNo(), query.inboundCode(),
query.page().intValue(), query.size().intValue());
if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "订单服务暂时不可用");
List<LastlegOrderSummary> rows = response.data().records().stream().map(this::preDepartureSummary).toList();
return new PageResult<>(rows, response.data().page(), response.data().size(), response.data().total(), response.data().pages());
return preDeparturePage(customerId, query);
}
if (query.sailingStage() == null && relabelNo == null) return aggregateAllPage(customerId, query);
Page<LastlegOrder> page = orderMapper.selectPage(
@@ -115,6 +111,27 @@ public class LastlegOrderService {
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
/** 开船前先扣除尾程已有实际开船事实,再分页,避免两页签重复及总数失真。 */
private PageResult<LastlegOrderSummary> preDeparturePage(String customerId, LastlegOrderRequests.Query query) {
var departed = preDepartureOrders.departedKeys(customerId);
var records = new java.util.ArrayList<LastlegOrderSummary>();
for (int sourcePage = 1; ; sourcePage++) {
var response = orderPreDepartureClient.page(internalServiceToken, parseLong(customerId),
query.orderNo(), query.inboundCode(), sourcePage, 2000);
if (response == null || !response.successful() || response.data() == null)
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "订单服务暂时不可用");
response.data().records().stream()
.filter(row -> !departed.contains(orderKey(String.valueOf(row.customerId()), row.orderNo())))
.map(this::preDepartureSummary).forEach(records::add);
if (sourcePage >= response.data().pages()) break;
}
int from = (int)Math.min((query.page() - 1) * query.size(), records.size());
int to = (int)Math.min(from + query.size(), records.size());
long total = records.size();
return new PageResult<>(records.subList(from, to), query.page(), query.size(), total,
total == 0 ? 0 : (total + query.size() - 1) / query.size());
}
/** 全部及已开船列表使用同一组业务事实,避免聚合列表丢失并托、排车和换标信息。 */
private List<LastlegOrderSummary> nativeSummaries(List<LastlegOrder> orders) {
Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(orders);
@@ -34,13 +34,24 @@ public final class StorageRentDtos {
public record Owner(String customerId, String customerName, String businessType, Long businessId,
String businessNo, Long orderId, String inboundCode, String warehouseId) { }
public record Allocation(long lotId, Long shareId, BigDecimal quantity, BigDecimal amount,
LocalDateTime inboundAt, long days, Long previouslyChargedDays) {
/** 中转按数量分摊及既有快照不携带退货整托已计费天数。 */
LocalDateTime inboundAt, long days, Long previouslyChargedDays,
BigDecimal actualVolumeCbm, List<String> cartonMarks) {
/** 实测体积与原箱身份一并冻结,消费时核对同一批实物。 */
public Allocation { cartonMarks=cartonMarks==null?List.of():List.copyOf(cartonMarks); }
/** 原托计费及历史快照不携带转运实测箱体积。 */
public Allocation(long lotId, Long shareId, BigDecimal quantity, BigDecimal amount,
LocalDateTime inboundAt, long days, Long previouslyChargedDays) {
this(lotId,shareId,quantity,amount,inboundAt,days,previouslyChargedDays,null,List.of());
}
/** 普通来源及既有快照不携带退货整托已计费天数。 */
public Allocation(long lotId, Long shareId, BigDecimal quantity, BigDecimal amount,
LocalDateTime inboundAt, long days) {
this(lotId, shareId, quantity, amount, inboundAt, days, null);
}
}
/** 转运原批次对应的当前实箱测量,体积由服务端按厘米换算。 */
public record TransferRentCarton(Long id,String cartonMark,String customerCartonMark,String status,
BigDecimal lengthCm,BigDecimal widthCm,BigDecimal heightCm) { }
public record PricingTier(String id,Integer startDay,Integer endDay,BigDecimal unitPrice,Integer sortNo,
String feeType,String billingUnit) { }
public record Pricing(String configId,Long customerId,Integer version,String currency,List<PricingTier> tiers,String source) {
@@ -3,9 +3,44 @@ package com.baiyue.lastleg.storagerent.mapper;
import com.baiyue.lastleg.storagerent.entity.StorageRentBusinessSource;
import org.apache.ibatis.annotations.*;
/** 同服务本地只读业务归属投影,不借用其他聚合的写 Mapper。 */
/** 同服务计租业务归属、实箱测量及随出库原子保存的箱级状态。 */
@Mapper
public interface StorageRentBusinessSourceMapper {
/** 只读取原实收批次、同客户同仓及当前托盘内的转运箱,禁止按箱数猜测体积。 */
@Select("""
SELECT c.id,c.carton_mark,c.customer_carton_mark,c.status,c.length_cm,c.width_cm,c.height_cm
FROM ll_storage_rent_lot l
JOIN ll_inbound_stock_receipt r ON l.source_key=CONCAT('INBOUND:',r.id) AND r.customer_id=l.customer_id
JOIN ll_warehouse_transfer_order t ON t.id=l.business_id AND t.customer_id=l.customer_id
AND t.operating_warehouse_id=l.warehouse_id
JOIN ll_inventory_position p ON p.id=#{position} AND p.customer_id=l.customer_id
AND p.overseas_warehouse_id=l.warehouse_id
JOIN ll_warehouse_transfer_pallet tp ON tp.transfer_order_id=t.id AND tp.customer_id=t.customer_id
AND tp.warehouse_pallet_id=r.warehouse_pallet_id AND tp.warehouse_pallet_id=p.pallet_id
AND tp.status='COMPLETED'
JOIN ll_warehouse_transfer_carton c ON c.transfer_order_id=t.id AND c.customer_id=t.customer_id
AND c.transfer_pallet_id=tp.id AND c.status IN ('COUNTED','DISCARD_PENDING')
JOIN ll_warehouse_transfer_item i ON i.id=c.transfer_item_id AND i.customer_id=c.customer_id
AND i.transfer_order_id=t.id AND i.sku_code=r.sku_code
WHERE l.id=#{lot} AND l.customer_id=#{customer} AND l.warehouse_id=#{warehouse}
AND l.business_type='WAREHOUSE_TRANSFER' AND l.rule_code='TRANSIT'
ORDER BY c.id
FOR UPDATE
""")
java.util.List<com.baiyue.lastleg.storagerent.dto.StorageRentDtos.TransferRentCarton> transferRentCartons(
@Param("customer") String customer,@Param("warehouse") String warehouse,
@Param("position") Long position,@Param("lot") long lot);
/** 普通出库同事务标记已计费实箱,后续部分出库不能重复选择原箱。 */
@Update("""
<script>
UPDATE ll_warehouse_transfer_carton SET status='SHIPPED',updated_at=CURRENT_TIMESTAMP
WHERE customer_id=#{customer} AND transfer_order_id=#{transfer} AND status='COUNTED'
AND carton_mark IN
<foreach collection="marks" item="mark" open="(" separator="," close=")">#{mark}</foreach>
</script>
""")
int shipTransferRentCartons(@Param("customer") String customer,@Param("transfer") Long transfer,
@Param("marks") java.util.List<String> marks);
/** 换标出库号解析该次独立服务单。 */
@Select("SELECT id,relabel_no AS business_no,lastleg_order_id FROM ll_relabel_application WHERE customer_id=#{customer} AND outbound_order_no=#{outbound} LIMIT 1")
StorageRentBusinessSource relabel(@Param("customer") String customer,@Param("outbound") String outbound);
@@ -22,6 +22,14 @@ import java.util.*;
/** 仓租全部持久化及同服务来源解析,服务不直接访问 Mapper。 */
@Repository
public class StorageRentRepository {
/** 转运计费只使用本仓位原实收批次可追溯的实箱尺寸。 */
public List<TransferRentCarton> transferRentCartons(String customer,String warehouse,Long position,long lot) {
return businessSources.transferRentCartons(customer,warehouse,position,lot);
}
/** 箱级状态与仓租及实际库存共同提交,废弃状态由废弃作业推进。 */
public int shipTransferRentCartons(String customer,Long transfer,List<String> marks) {
return marks.isEmpty()?0:businessSources.shipTransferRentCartons(customer,transfer,marks);
}
@Resource private StorageRentLotMapper lots;
@Resource private StorageRentShareMapper shares;
@Resource private StorageRentPreparationMapper preparations;
@@ -95,6 +95,7 @@ public class StorageRentReviewService implements StorageRentFeeWorkflow {
boolean whole=parts.get(0).previouslyChargedDays()!=null;
var quantity=parts.stream().map(Allocation::quantity).reduce(BigDecimal.ZERO,BigDecimal::add);
var units=whole?lot.getOriginalUnits().setScale(0,RoundingMode.CEILING)
:parts.get(0).actualVolumeCbm()!=null?parts.stream().map(Allocation::actualVolumeCbm).reduce(BigDecimal.ZERO,BigDecimal::add)
:lot.getOriginalUnits().multiply(quantity).divide(lot.getOriginalQuantity(),12,RoundingMode.HALF_UP);
long previous=whole?parts.get(0).previouslyChargedDays():0;long days=parts.get(0).days();
calculations.add(new Calculation(id,whole?"RETURN_PALLET":"RETURN".equals(lot.getRuleCode())?"RETURN_SHARE":"TRANSIT",
@@ -298,6 +298,10 @@ public class StorageRentService implements WalletResultHandler {
for(var allocation:saved.allocations().stream().sorted(Comparator.comparingLong(Allocation::lotId)).toList()){
var lot=repository.lockLot(allocation.lotId());
if(lot==null||!value.getCustomerId().equals(lot.getCustomerId())||lot.getRemainingQuantity().compareTo(allocation.quantity())<0)throw conflict("原计租批次剩余数量已变化");
// 转运按实箱结算,普通出库立即保留箱级离仓事实;废弃仍由原扣减流程改为 DISCARDED。
if(allocation.actualVolumeCbm()!=null&&!"DISCARD".equals(request.mode())
&&repository.shipTransferRentCartons(value.getCustomerId(),lot.getBusinessId(),allocation.cartonMarks())!=allocation.cartonMarks().size())
throw conflict("转运计租实箱已出库或被其他作业占用,请重新预校验");
if(allocation.shareId()!=null){var share=repository.lockShare(allocation.shareId());
if(share==null||share.getQuantity().compareTo(allocation.quantity())<0)throw conflict("退货计租商品份额已变化");
share.setQuantity(share.getQuantity().subtract(allocation.quantity()));repository.save(share);}
@@ -447,12 +451,14 @@ public class StorageRentService implements WalletResultHandler {
if("SETTLE".equals(operation))value.setSettleEventId(event);else value.setReleaseEventId(event);
value.setErrorMessage(null);value.setUpdatedAt(now);repository.save(value);
}
/** 原托计费期间变化必须重建预占释放旧冻结,避免两次并发出库重复收整托。 */
/** 原托期间、转运实际体积或选箱变化必须重建预占,不能沿用其他货物的冻结。 */
private static boolean sameBasis(Plan a,Plan b) {
if(!Objects.equals(a.owner(),b.owner())||a.allocations().size()!=b.allocations().size())return false;
for(int i=0;i<a.allocations().size();i++){var x=a.allocations().get(i);var y=b.allocations().get(i);
if(x.lotId()!=y.lotId()||!Objects.equals(x.shareId(),y.shareId())||x.quantity().compareTo(y.quantity())!=0||x.days()!=y.days()
||!Objects.equals(x.previouslyChargedDays(),y.previouslyChargedDays()))return false;}
||!Objects.equals(x.previouslyChargedDays(),y.previouslyChargedDays())
||!Objects.equals(x.cartonMarks(),y.cartonMarks())
||(x.actualVolumeCbm()==null?y.actualVolumeCbm()!=null:y.actualVolumeCbm()==null||x.actualVolumeCbm().compareTo(y.actualVolumeCbm())!=0))return false;}
return true;
}
/** 返回前端可执行标记;只有已确认冻结或原已消费请求可继续原扫码幂等恢复。 */
@@ -31,7 +31,7 @@ public class StorageRentSourceResolver {
com.baiyue.lastleg.storagerent.gateway.StorageRentPricingGateway.validate(snapshot,Long.parseLong(plan.owner().customerId()));
return reprice(plan,snapshot);
}
/** 中转按份额计价;退货逐原托只收一次未计费期间,不能按件摊薄或逐箱重复收整托。 */
/** 转运按实际出库箱体积计价;退货逐原托只收一次未计费期间。 */
private Plan reprice(Plan plan,Pricing pricing) {
var allocations=new ArrayList<Allocation>();
var chargedLots=new HashSet<Long>();
@@ -43,7 +43,12 @@ public class StorageRentSourceResolver {
?StorageRentCalculator.returnPalletAmount(pricing,lot.getInboundAt(),plan.calculatedAt(),lot.getOriginalUnits(),previous)
:BigDecimal.ZERO;
allocations.add(new Allocation(a.lotId(),a.shareId(),a.quantity(),amount,a.inboundAt(),a.days(),previous));
} else allocations.add(new Allocation(a.lotId(),a.shareId(),a.quantity(),StorageRentCalculator.amount(pricing,lot.getRuleCode(),lot.getInboundAt(),plan.calculatedAt(),lot.getOriginalUnits(),lot.getOriginalQuantity(),a.quantity()),a.inboundAt(),a.days()));
} else {
var amount=a.actualVolumeCbm()==null
?StorageRentCalculator.amount(pricing,lot.getRuleCode(),lot.getInboundAt(),plan.calculatedAt(),lot.getOriginalUnits(),lot.getOriginalQuantity(),a.quantity())
:StorageRentCalculator.unitAmount(pricing,"TRANSIT",a.days()).multiply(a.actualVolumeCbm());
allocations.add(new Allocation(a.lotId(),a.shareId(),a.quantity(),amount,a.inboundAt(),a.days(),null,a.actualVolumeCbm(),a.cartonMarks()));
}
}
var calculations=new ArrayList<Calculation>();
for(long id:allocations.stream().map(Allocation::lotId).distinct().toList()) {
@@ -51,6 +56,7 @@ public class StorageRentSourceResolver {
var quantity=parts.stream().map(Allocation::quantity).reduce(BigDecimal.ZERO,BigDecimal::add);
boolean returns="RETURN".equals(lot.getRuleCode());long previous=returns?lot.getChargedDays():0;
var units=returns?lot.getOriginalUnits().setScale(0,RoundingMode.CEILING)
:parts.get(0).actualVolumeCbm()!=null?parts.stream().map(Allocation::actualVolumeCbm).reduce(BigDecimal.ZERO,BigDecimal::add)
:lot.getOriginalUnits().multiply(quantity).divide(lot.getOriginalQuantity(),12,RoundingMode.HALF_UP);
long days=parts.get(0).days();
calculations.add(new Calculation(id,returns?"RETURN_PALLET":"TRANSIT",units,returns?"PALLET":"CBM",days,previous,
@@ -125,7 +131,9 @@ public class StorageRentSourceResolver {
}
var total=shares.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);
if(total.compareTo(BigDecimal.valueOf(request.packages()))<0)throw conflict("原实收批次计租数量不足,不能出库");
allocations=proportionShares(shares,BigDecimal.valueOf(request.packages()),total,at);
var selectedMarks=request.cartonMarks()==null?List.<String>of():request.cartonMarks();
if(selectedMarks.isEmpty()&&request.cartonMark()!=null&&!request.cartonMark().isBlank())selectedMarks=List.of(request.cartonMark());
allocations=stockAllocations(shares,position,request.packages(),selectedMarks,false,at);
if(owner.businessId()==null||owner.businessNo()==null)owner=business(customer,balance.getCustomerName(),"LASTLEG_ORDER",null,balance.getInboundOrderId(),warehouse,balance.getInboundCode());
}
return new Plan(owner,allocations,at,allocations.stream().map(Allocation::amount).reduce(BigDecimal.ZERO,BigDecimal::add).setScale(2,RoundingMode.HALF_UP));
@@ -153,7 +161,13 @@ public class StorageRentSourceResolver {
if(shares.isEmpty()){
var balance=repository.balance(position.getBalanceId());var order=balance==null?null:repository.order(balance.getInboundOrderId());
if(order==null||StorageRentService.eligible(order))throw conflict("废弃原实收批次缺少计租记录");
}else if(line.actualPackages()>0){var total=shares.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);if(total.compareTo(BigDecimal.valueOf(line.actualPackages()))<0)throw conflict("废弃原计租批次数量不足");result.addAll(proportionShares(shares,BigDecimal.valueOf(line.actualPackages()),total,at));}
}else if(line.actualPackages()>0){
var links=discards.cartons(customer,application.getId()).stream().filter(c->item.getId().equals(c.getItemId())).map(c->c.getCartonMark()).toList();
var scanned=line.scannedCartonMarks()!=null?line.scannedCartonMarks():request.scannedCartonMarks();
// 有申请选箱时只从本行原箱中取实际扫码箱,不能借其他行箱唛扩大计费范围。
var selected=scanned==null||scanned.isEmpty()?links:links.isEmpty()?scanned:links.stream().filter(scanned::contains).toList();
result.addAll(stockAllocations(shares,position,line.actualPackages(),selected,true,at));
}
}
if(!Objects.equals(pallet==null||pallet.isBlank()?"散箱":pallet.trim(),request.palletNo().trim()))throw conflict("废弃明细不属于本次托盘");
}
@@ -177,6 +191,46 @@ public class StorageRentSourceResolver {
for(int i=0;i<shares.size();i++){var share=shares.get(i);var part=i==shares.size()-1?quantity.subtract(used):quantity.multiply(share.getQuantity()).divide(total,12,RoundingMode.DOWN);used=used.add(part);if(part.signum()>0)result.add(allocation(repository.lockLot(share.getLotId()),share.getId(),part,at));}
return result;
}
/** 转运按实际箱尺寸计费,数量仅用于扣减原库存;整托也必须有完整箱级测量。 */
private List<Allocation> stockAllocations(List<StorageRentShare> shares,
com.baiyue.lastleg.inventory.entity.InventoryPosition position,long packages,List<String> marks,
boolean discard,LocalDateTime at) {
var total=shares.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);
if(total.compareTo(BigDecimal.valueOf(packages))<0)throw conflict("原实收批次计租数量不足,不能出库");
var transfer=shares.stream().filter(s->{var lot=repository.lockLot(s.getLotId());return lot!=null&&"WAREHOUSE_TRANSFER".equals(lot.getBusinessType())&&"TRANSIT".equals(lot.getRuleCode());}).toList();
if(transfer.isEmpty())return proportionShares(shares,BigDecimal.valueOf(packages),total,at);
if(transfer.size()!=shares.size())throw conflict("转运计租来源混合,需按实际箱核对原批次");
var selected=new HashSet<String>();
for(String mark:marks)if(mark==null||mark.isBlank()||!selected.add(mark.trim()))throw conflict("转运计租箱唛不能为空或重复");
var result=new ArrayList<Allocation>();var matched=new HashSet<String>();var physical=new HashSet<Long>();long count=0;
for(var share:transfer){
var lot=repository.lockLot(share.getLotId());
var boxes=repository.transferRentCartons(position.getCustomerId(),position.getOverseasWarehouseId(),position.getId(),lot.getId());
var chosen=boxes.stream().filter(c->discard?"DISCARD_PENDING".equals(c.status()):"COUNTED".equals(c.status()))
.filter(c->selected.isEmpty()||selected.contains(c.cartonMark())||selected.contains(c.customerCartonMark())).toList();
if(chosen.isEmpty())continue;
BigDecimal volume=BigDecimal.ZERO;var canonical=new ArrayList<String>();
for(var box:chosen){
if(!physical.add(box.id())||box.cartonMark()==null)throw conflict("转运箱的原计租批次不唯一");
if(box.lengthCm()==null||box.widthCm()==null||box.heightCm()==null
||box.lengthCm().signum()<=0||box.widthCm().signum()<=0||box.heightCm().signum()<=0)throw conflict("转运箱缺少实际长宽高,不能计算仓租");
volume=volume.add(box.lengthCm().multiply(box.widthCm()).multiply(box.heightCm()).movePointLeft(6));
canonical.add(box.cartonMark());
if(selected.contains(box.cartonMark()))matched.add(box.cartonMark());
if(selected.contains(box.customerCartonMark()))matched.add(box.customerCartonMark());
}
var quantity=BigDecimal.valueOf(chosen.size());
if(quantity.compareTo(share.getQuantity())>0)throw conflict("转运实箱与原计租剩余数量不一致");
canonical.sort(String::compareTo);long days=StorageRentCalculator.days(lot.getInboundAt(),at);
result.add(new Allocation(lot.getId(),share.getId(),quantity,
StorageRentCalculator.unitAmount("TRANSIT",days).multiply(volume),lot.getInboundAt(),days,null,volume,canonical));
count+=chosen.size();
}
// 无逐箱扫码仅允许本仓位全部实箱一次出库,部分出库不得挑选平均体积或猜箱。
if(count!=packages||!matched.equals(selected)||!selected.isEmpty()&&selected.size()!=packages)
throw conflict("转运仓租需提交本次实际出库箱唛,数量或原批次尺寸记录不完整");
return result;
}
/** 商品份额仅追溯原托及扣减库存;金额在完整扫码汇总后按原托去重计算。 */
private List<Allocation> returnAllocations(String customer,String warehouse,String mark,String sku,Long quantity,LocalDateTime at) {
var carton=repository.carton(mark);if(carton==null||!customer.equals(carton.getCustomerId())||!warehouse.equals(carton.getOverseasWarehouseId()))throw conflict("原退货箱不属于当前客户和仓库");
@@ -21,26 +21,26 @@ public class MerchantOrderBudgetController {
@NotBlank @Pattern(regexp="CNY|USD") String currency) { }
/** 按业务创建权限发起预估冻结。 */
@PostMapping("/{kind}/{key}")
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))")
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create')) or (#kind == 'looseboard' and hasAuthority('merchant:cross-border:pallet-shipping:create'))")
public ApiResponse<OrderBudgetService.View> prepare(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable @Size(max=128) String key,@Valid @RequestBody Prepare request) {
return ApiResponse.success(service.prepare(customer(user),kind,key,request.amount(),request.currency()));
}
/** 只读资金回执沿同一业务权限。 */
@GetMapping("/{kind}/{key}")
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))")
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create')) or (#kind == 'looseboard' and hasAuthority('merchant:cross-border:pallet-shipping:create'))")
public ApiResponse<OrderBudgetService.View> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable String key) {
return ApiResponse.success(service.read(customer(user),kind,key));
}
/** 列表及详情可核对当前客户订单资金状态和失败原因。 */
@GetMapping("/{kind}/orders/{orderNo}")
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:list')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:list'))")
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:list')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:list')) or (#kind == 'looseboard' and hasAuthority('merchant:cross-border:pallet-shipping:list'))")
public ApiResponse<OrderBudgetService.View> readOrder(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String kind, @PathVariable String orderNo) {
return ApiResponse.success(service.readOrder(customer(user), kind, orderNo));
}
/** 失败订单按原预算重试;客户身份只取当前会话。 */
@PostMapping("/{kind}/orders/{orderNo}/retry")
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))")
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create')) or (#kind == 'looseboard' and hasAuthority('merchant:cross-border:pallet-shipping:create'))")
public ApiResponse<OrderBudgetService.View> retry(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String kind, @PathVariable String orderNo) {
return ApiResponse.success(service.retryOrder(customer(user), kind, orderNo));
@@ -24,6 +24,7 @@ public interface OrderBudgetMapper extends BaseMapper<OrderBudget> {
(b.order_no IS NULL AND b.created_at < #{expiredBefore})
OR (b.kind='procurement' AND EXISTS (SELECT 1 FROM ll_overseas_procurement_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED','EXCEPTION_CLOSED')))
OR (b.kind='trucking' AND EXISTS (SELECT 1 FROM ll_us_truck_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED')))
OR (b.kind='looseboard' AND EXISTS (SELECT 1 FROM ll_local_looseboard_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('WAITING_INBOUND','RECEIVING','WAITING_CONSOLIDATION','PENDING_DISPATCH','DISPATCHED','IN_TRANSIT','CANCELLED','COMPLETED')))
)) ORDER BY b.id LIMIT 100 FOR UPDATE SKIP LOCKED
""")
List<OrderBudget> releasable(@Param("expiredBefore") LocalDateTime expiredBefore,
@@ -115,7 +115,15 @@ public class OrderBudgetService implements WalletResultHandler {
return change.get();
}
/** 审核通过后立即释放采购或卡车预估冻结,后续实际费用按正常余额/授信扣除。 */
/** 审核及取消先锁预算再锁业务单,与异步资金回执保持相同锁序。 */
@Transactional
public <T> T withOrder(String customer,String kind,String no,Supplier<T> change) {
requireKind(kind);
repository.lockOrder(customer,kind,no);
return change.get();
}
/** 审核通过后立即释放订单预估冻结,后续实际费用按正常余额/授信扣除。 */
@Transactional
public void releaseAfterReview(String customer, String kind, String orderNo) {
requireKind(kind);
@@ -194,7 +202,7 @@ public class OrderBudgetService implements WalletResultHandler {
};return new View(row.getStatus(),row.getAmountCny(),message,row.getOrderNo(),"FROZEN".equals(row.getStatus()) && !Boolean.TRUE.equals(row.getReleaseRequested()) && (row.getOrderNo()!=null || !row.getCreatedAt().isBefore(now().minusMinutes(15))));
}
/** 仅开放本轮客户下单业务。 */
private void requireKind(String kind) { if(!Set.of("procurement","trucking").contains(kind)) throw bad("不支持的预算业务"); }
private void requireKind(String kind) { if(!Set.of("procurement","trucking","looseboard").contains(kind)) throw bad("不支持的预算业务"); }
/** 时间按UTC保存以匹配资金事件。 */
private LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); }
/** 返回可恢复的业务校验失败。 */
@@ -3,6 +3,8 @@ info:
title: 本土散板排车照片履约
version: 1.0.0
description: >-
管理资料按同客户散板原单聚合,每单展示一块并汇总本车各托箱数。itemId为该订单在本车的代表明细,
同单照片一次保存并在同一事务保留各托证据;不同客户或不同订单不能共用节点照片,分车仍分别履约。
仅同客户权威关联的本土散板明细进入照片节点。全车为散板且尚无真实扫码时,审核通过后逐票保存取货照片,
全部齐全才显示取货完成按钮;显式确认后从LOADING进入DELIVERING。不生成库存、PDA拣货或仓库出库事实。逐票保存送达照片后自动DELIVERED并结案释放车辆司机。
混排从AFTER_LOADING或DELIVERING可逐票保存取货及送达照片;异地货同票须先取货再送达,不同票不限制取送顺序。
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: 派送异常退回入库
version: 1.0.0
description: 排车异常冻结原出库货物,异常单生成退回尾程单后进入PDA退货入库列表。现场展示异常单号、车牌和待收箱数,点击开始作业,不需要回单号或扫码找单。建单不增加库存,分批实收调用原来源RETURN_STOCK_IN或原箱恢复,异常和退回尾程单收齐后完成。
description: 排车异常允许一次选择同派送下多张原订单,逐原单生成异常并冻结对应原出库货物,整批校验与落单原子提交。异常单生成退回尾程单后进入PDA退货入库列表。现场展示异常单号、车牌和待收箱数,点击开始作业,不需要回单号或扫码找单。建单不增加库存,分批实收调用原来源RETURN_STOCK_IN或原箱恢复,异常和退回尾程单收齐后完成。回仓扫描当前仓有效托盘及其实际库位,不要求使用已出库报废的原托;同键实收重放不重复增加库存。
paths:
/api/platform/v1/last-leg/exception-orders/return-inbound-plans:
post:
@@ -13,7 +13,7 @@
"paths": {
"/api/platform/v1/last-leg/fleet/operations": {
"get": {
"description": "platform:fleet:operations:list;一次查询返回分页与全范围汇总",
"description": "platform:fleet:operations:list;一次查询返回分页与全范围汇总。纯散板照片直送按LOOSEBOARD_DELIVERING不可变历史的实际发运箱数统计,要求已有实际发车时间且未发生仓内拣货或装车;实托和标准托位保持null,不以申报托数补齐。普通仓内出车仍要求DEPART实载快照。",
"parameters": [
{
"name": "page",
@@ -793,6 +793,7 @@ components:
Cargo:
type: "object"
x-java-record: "OwnedBusinessModels.Cargo"
description: 散板按客户和商业订单一行展示,关联托盘码去重连接,箱数按本单来源累加;其他货物保持原来源行。
required:
- "sourceKey"
- "customerId"
@@ -815,10 +816,19 @@ components:
nullable: true
palletNo:
type: "string"
nullable: true
description: 关联的完整实托码集合,以顿号连接;未提供保持未知。
destinationKey:
type: "string"
description: 首个目的地键;展示使用完整destinationKeys集合。
destinationKeys:
type: array
items:
type: string
description: 本订单全部真实目的地,不能丢失多目的地信息。
packages:
type: "string"
nullable: true
Detail:
type: "object"
x-java-record: "OwnedBusinessModels.Detail"
@@ -2,17 +2,17 @@ openapi: 3.0.3
info:
title: 海外客户下单预估资金校验
version: 1.0.0
description: 客户订单先原子落库并进入资金处理中,资金冻结通过Outbox异步执行。冻结成功后进入待审核,失败进入资金失败并可在原订单上重试;平台仅可审核待审核订单。余额优先、授信补足及不足判断由Finance原子执行;本服务不自行修改钱包。预算预占、订单和冻结指令在同一事务内写入,订单终态释放。
description: 客户订单先原子落库并进入资金处理中,资金冻结通过Outbox异步执行。冻结成功后进入待审核,失败进入资金失败并可在原订单上重试;平台仅可审核待审核订单。余额优先、授信补足及不足判断由Finance原子执行;本服务不自行修改钱包。预算预占、订单和冻结指令在同一事务内写入,审核通过立即申请释放原预算,取消、驳回或终态同样释放;释放失败自动重试。散板冻结成功进入PENDING_ACCEPTANCE,失败进入FUNDING_FAILED。
security:
- bearerAuth: []
paths:
/api/merchant/v1/last-leg/order-budgets/{kind}/{key}:
parameters:
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}}
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking, looseboard]}}
- {name: key, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
post:
summary: 兼容接口:发起或读取预算冻结请求
description: 客户身份仅来自认证。procurement需要merchant:cross-border:procurement:create,trucking需要merchant:cross-border:domestic-trucking:create;权限不能交叉使用。采购金额是预算单价乘采购数量,免费样品零金额不发资金指令;卡车金额必须大于0。重试禁止更改金额或币种。
description: 客户身份仅来自认证。procurement需要merchant:cross-border:procurement:create,trucking需要merchant:cross-border:domestic-trucking:create,looseboard需要merchant:cross-border:pallet-shipping:create;权限不能交叉使用。采购金额是预算单价乘采购数量,免费样品零金额不发资金指令;卡车金额必须大于0。重试禁止更改金额或币种。
requestBody:
required: true
content:
@@ -34,16 +34,16 @@ paths:
'403': {description: 身份或业务创建权限不足}
/api/merchant/v1/last-leg/order-budgets/{kind}/orders/{orderNo}:
parameters:
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}}
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking, looseboard]}}
- {name: orderNo, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
get:
summary: 读取订单资金状态
description: 仅当前客户可读。FUNDING_PENDING期间客户端可短暂轮询;FUNDING_FAILED展示失败原因并允许重试。只有PENDING_REVIEW允许平台审核。
description: 仅当前客户可读。FUNDING_PENDING期间客户端可短暂轮询;FUNDING_FAILED展示失败原因并允许重试。采购和卡车只有PENDING_REVIEW、散板只有PENDING_ACCEPTANCE且资金已冻结才允许平台审核。读取订单资金状态分别要求对应业务list权限,重试要求对应create权限。
responses:
'200': {$ref: '#/components/responses/Result'}
/api/merchant/v1/last-leg/order-budgets/{kind}/orders/{orderNo}/retry:
parameters:
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}}
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking, looseboard]}}
- {name: orderNo, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
post:
summary: 重试订单资金冻结
@@ -14,7 +14,7 @@ paths:
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 50, default: 20 } }
responses: { '200': { description: 统一ApiResponse;data为正式排车CandidateList,含候选来源版本、目的地及资格原因 } }
responses: { '200': { description: 统一ApiResponse;data为CandidateList,同客户散板原单一行且按订单分页,total为订单数;availablePackages汇总本车可选的同单各托,selectionGroup包含该单所有来源及原始版本,任一来源阻断则整单不可选,加入时一次展开全部来源 } }
post:
summary: 将已收货散板加入需求排车
description: 需capacity-offer:cargo-add或manage以及dispatch:edit、dispatch:list权限;复用正式AddCargo命令,校验版本、幂等、同仓及整托占用,自动刷新目的站和货量并失效审核,不执行出库拣货。
@@ -28,7 +28,7 @@ paths:
/api/platform/v1/last-leg/local-looseboard-orders/{orderNo}/review:
post:
summary: 审核散板并创建唯一入仓履约单
description: 权限platform:overseas-business:looseboard:status;通过后送货到仓进入PDA收货,仓库上门取货进入取货排车;审核后即可从散板需求添加申报货物;上门取货沿取货排车上传取货与到仓凭证,实收到仓后原单进入PENDING_DISPATCH并交接原排车货量。客户及幂等键严格校验。
description: 权限platform:overseas-business:looseboard:status;仅冻结成功可通过审核,通过和驳回均释放原预算预占;通过后送货到仓进入PDA收货,仓库上门取货进入取货排车;审核后即可从散板需求添加申报货物;上门取货沿取货排车上传取货与到仓凭证,实收到仓后原单进入PENDING_DISPATCH并交接原排车货量。客户及幂等键严格校验。
security: [{ bearerAuth: [] }]
parameters:
- { name: orderNo, in: path, required: true, schema: { type: string } }
@@ -773,7 +773,7 @@ paths:
/api/platform/v1/last-leg/inbound-plans/container-preview:
get:
summary: 预览按柜生成入库计划
description: 权限 platform:overseas-warehouse:inbound-order:list;按稳定 sourceContainerBookingId 返回不受订单分页影响的全柜事实。分组固定为 FBA、一件代发、私人地址,FBA 按目的仓编码/名称/ID 稳定排序;托数及 readiness/blockers 只由服务端计算。确认时必须原样回传 snapshotVersion 与 snapshotHash,服务端仍会锁柜重算。
description: 权限 platform:overseas-warehouse:inbound-order:list;按稳定 sourceContainerBookingId 返回不受订单分页影响的全柜事实。分组固定为 FBA、一件代发、私人地址,FBA 按目的仓编码/名称/ID 稳定排序;私人地址允许目的仓 ID 和编码为空,仍须具备票内一致的非空地址名称和有效接柜仓绑定。托数及 readiness/blockers 只由服务端计算。确认时必须原样回传 snapshotVersion 与 snapshotHash,服务端仍会锁柜重算。
security: [{ bearerAuth: [] }]
parameters:
- { name: sourceContainerBookingId, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
@@ -2754,20 +2754,20 @@ paths:
/api/merchant/v1/last-leg/local-looseboard-orders:
get:
summary: 客户分页查询自己的本土散板订单
description: 权限 merchant:cross-border:pallet-shipping:list;客户范围只取认证 Token,不接收 customerId。
description: 权限 merchant:cross-border:pallet-shipping:list;客户范围只取认证 Token,不接收 customerId;状态筛选、分页计数和显示共用真实运输进度。PENDING_ACCEPTANCE 为待审核,审核通过尚未排车为 PENDING_DISPATCH(待排车);已排车待取货为 DISPATCHED;取货到仓车次出发或已取货未到仓为 PICKING_UP(取货中),到仓后等待后续排车;直接取货送货取货完成后为 IN_TRANSIT(派送中)。内部收货与合托状态不作为客户筛选项。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: orderNo, in: query, schema: { type: string } }
- { name: routeKeyword, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/LooseboardStatus' } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/MerchantLooseboardStatus' } }
- { name: requestedFrom, in: query, schema: { type: string, format: date } }
- { name: requestedTo, in: query, schema: { type: string, format: date } }
responses: { '200': { description: 当前客户本土散板订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardPageResponse' } } } } }
post:
summary: 客户创建本土散板订单
description: 权限 merchant:cross-border:pallet-shipping:create;新单固定进入 PENDING_ACCEPTANCE,客户不能调用平台状态推进端点。
description: 权限 merchant:cross-border:pallet-shipping:create;散板金额必填,预算与订单和冻结指令同事务保存;新单进入 FUNDING_PENDING,Finance按余额优先、授信补足冻结,成功进入 PENDING_ACCEPTANCE,失败进入 FUNDING_FAILED,支持原单重试。审核通过、取消或驳回申请释放预占,不自动扣款;客户不能调用平台状态推进端点。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardCreate' } } } }
responses: { '200': { description: 本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 幂等冲突 } }
@@ -2775,12 +2775,12 @@ paths:
parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }]
get:
summary: 客户查询自己的本土散板订单详情
description: 权限 merchant:cross-border:pallet-shipping:list;不泄露其他客户订单。
description: 权限 merchant:cross-border:pallet-shipping:list;不泄露其他客户订单;summary.status 使用 MerchantLooseboardStatus,与列表运输状态及筛选一致。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 当前客户本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } }
put:
summary: 客户编辑待接单的本土散板订单
description: 权限 merchant:cross-border:pallet-shipping:edit;仅 PENDING_ACCEPTANCE 可编辑,version 必填;不能修改状态。
summary: 客户编辑待审核的本土散板订单
description: 权限 merchant:cross-border:pallet-shipping:edit;仅 PENDING_ACCEPTANCE 可编辑,version 必填;已冻结的预算金额与币种不能通过编辑更改;不能修改状态。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardUpdate' } } } }
responses: { '200': { description: 更新后的订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 版本或状态冲突 } }
@@ -4897,15 +4897,19 @@ components:
destinationContactPhone: { type: string, nullable: true }
pickupAt: { type: string, format: date-time, nullable: true, description: 当地日期时间不带时区;按分钟输入 }
deliveryAt: { type: string, format: date-time, nullable: true, description: 当地日期时间,不能早于取货时间 }
status: { $ref: '#/components/schemas/LooseboardStatus' }
status:
description: 客户读取为运输状态 MerchantLooseboardStatus,平台读取保留内部作业状态 LooseboardStatus。
anyOf:
- { $ref: '#/components/schemas/LooseboardStatus' }
- { $ref: '#/components/schemas/MerchantLooseboardStatus' }
version: { type: integer, format: int64 }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
fleetArrangement: { $ref: '#/components/schemas/FleetArrangement' }
LooseboardDetail:
type: object
description: 承运安排位于summary.fleetArrangement,与列表摘要使用同一真实来源;根对象不重复该字段。池来源状态只能由实际履约推进。
required: [summary, histories]
description: 承运安排位于summary.fleetArrangement;dispatchNos独立按本客户散板原单的真实来源返回全部有效排车,不依赖车队预订,同车多托去重、分车全部展示、取消排车排除;未排车返回空数组。池来源状态只能由实际履约推进。
required: [summary, histories, dispatchNos]
properties:
summary: { $ref: '#/components/schemas/LooseboardSummary' }
originAddress: { type: string, nullable: true }
@@ -4913,6 +4917,7 @@ components:
remark: { type: string, nullable: true }
createdBy: { type: string, nullable: true }
histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } }
dispatchNos: { type: array, uniqueItems: true, items: { type: string }, description: 本客户当前散板单关联的全部有效排车单号,不授予客户整车详情访问权限 }
FleetArrangement:
type: object
nullable: true
@@ -5291,7 +5296,11 @@ components:
pickupAt: { type: string, format: date-time, description: '取货日期时分,使用无偏移量的本地时间,不转换时区' }
deliveryAt: { type: string, format: date-time, description: '送货日期时分,不能早于取货时间,不转换时区' }
remark: { type: string, nullable: true, maxLength: 1000 }
LooseboardStatus: { type: string, enum: [PENDING_ACCEPTANCE, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] }
LooseboardStatus: { type: string, enum: [FUNDING_PENDING, FUNDING_FAILED, PENDING_ACCEPTANCE, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] }
MerchantLooseboardStatus:
type: string
description: 订单受理中、订单处理失败、待审核、待排车、待取货、取货中、派送中、已完成、已取消;只读运输进度,不作为平台状态推进命令。
enum: [FUNDING_PENDING, FUNDING_FAILED, PENDING_ACCEPTANCE, PENDING_DISPATCH, DISPATCHED, PICKING_UP, IN_TRANSIT, COMPLETED, CANCELLED]
LooseboardStatusChange:
type: object
required: [version, status, idempotencyKey]
@@ -5302,7 +5311,7 @@ components:
remark: { type: string, nullable: true, maxLength: 1000 }
LooseboardCreate:
type: object
required: [operatingWarehouseId, deliveryMode, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey]
required: [operatingWarehouseId, deliveryMode, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, budgetAmount, idempotencyKey]
properties:
deliveryMode: { type: string, enum: [DELIVERY_TO_WAREHOUSE, WAREHOUSE_PICKUP], description: 送货到仓或仓库上门取货 }
operatingWarehouseId: { type: string, description: 实际收货海外仓ID }
@@ -5321,7 +5330,7 @@ components:
packageCount: { type: integer, minimum: 0 }
totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 }
budgetAmount: { type: number, minimum: 0, multipleOf: 0.01 }
currency: { type: string, minLength: 3, maxLength: 3, default: USD }
originContactName: { type: string, minLength: 1, maxLength: 128 }
originContactPhone: { type: string, minLength: 1, maxLength: 128 }
@@ -6771,6 +6780,7 @@ components:
DispatchOrderDetail:
type: object
x-java-record: DispatchViews.OrderDetail
description: 散板按客户和权威商业订单汇总一行,实托去重计数;重量体积仅完整可分摊时汇总,未知不补零。普通来源保持原展示语义。
properties:
customerId:
type: string
@@ -6808,6 +6818,11 @@ components:
sourceBusinessNo:
type: string
nullable: true
sourceKeys:
type: array
items:
type: string
description: 本展示行包含的全部原来源键,只用于只读关联,不能作为新增或删除货物的命令身份。
required:
- customerId
- customerName
@@ -6821,6 +6836,7 @@ components:
- sourceKey
- sourceBusinessType
- sourceBusinessNo
- sourceKeys
DispatchDetail:
type: object
x-java-record: DispatchViews.Detail
@@ -8504,7 +8520,9 @@ components:
pattern: ^[1-9][0-9]*$
returnSelections:
type: array
description: 退回海外仓必填。来源必须属于本车派送,箱数不超过原拣货及未签收余量。
description: 退回海外仓必填,可同时选择同一派送下多张原订单,每个deliveryNo和lastMileOrderId组合只允许一项,各原单分别生成异常。同一派送在行锁内核验批次总退回量不超过未签收余量;所有来源属于本车及对应原单,任一失败整批回滚,原幂等键重放不重复建单或冻结。
minItems: 1
maxItems: 200
items:
type: object
required: [deliveryNo, lastMileOrderId, items]

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