Compare commits

..
12 Commits
95 changed files with 2449 additions and 215 deletions
+8
View File
@@ -125,6 +125,14 @@ pipeline {
git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}" git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}"
git checkout --detach "${RESOLVED_SOURCE_COMMIT}" git checkout --detach "${RESOLVED_SOURCE_COMMIT}"
test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{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 if [ "$RESOLVED_ENVIRONMENT" = "production" ]; then
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
git merge-base --is-ancestor "$RESOLVED_SOURCE_COMMIT" origin/main || { git merge-base --is-ancestor "$RESOLVED_SOURCE_COMMIT" origin/main || {
+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" SERVICE="$4"
ENV_FILE="$5" ENV_FILE="$5"
COMPOSE_FILE="deploy/docker-compose.yml" COMPOSE_FILE="deploy/docker-compose.yml"
TEST_LIMITS_FILE="deploy/docker-compose.test-limits.yml"
IMAGE="$IMAGE_REPOSITORY/$SERVICE:$IMAGE_TAG" IMAGE="$IMAGE_REPOSITORY/$SERVICE:$IMAGE_TAG"
REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}" REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}"
PROJECT_NAME="firstleg-$DEPLOY_ENV" 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 if [ "$DEPLOY_ENV" = "test" ] && [ "${TARGET_IS_LOCAL:-false}" != "true" ]; then
echo "TEST deploy must run on the target host" >&2; exit 2 echo "TEST deploy must run on the target host" >&2; exit 2
fi 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; } [ -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'; } 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 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 if [ -n "${deploy_docker_config:-}" ]; then rm -rf -- "$deploy_docker_config"; fi
} }
# TEST 的查询、部署和回滚共用同一份内存限额,缺失时由入口提前拒绝。
local_compose() { local_compose() {
if [ "$DEPLOY_ENV" = "test" ]; then
set -- -f "$TEST_LIMITS_FILE" "$@"
fi
docker compose \ docker compose \
--project-name "$PROJECT_NAME" \ --project-name "$PROJECT_NAME" \
--env-file "$ENV_FILE" \ --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`。 `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 或部署。 退货仓租改为原托整托计费、余一箱仍计一托,现有阶梯不变。已有 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;
@@ -49,6 +49,7 @@ enum BusinessFileCategory {
OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), 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_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, 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_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
LOADING_LIST_CUSTOMS_RECEIPT(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), LOADING_LIST_CUSTOMS_RESULT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
@@ -135,11 +136,12 @@ enum BusinessFileCategory {
return Long.toString(value); 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 enum BusinessIdPolicy { POSITIVE_LONG, ORDER_BATCH_NO }
private static final class Limits { private static final class Limits {
private static final long STANDARD = 10L * 1024 * 1024; private static final long STANDARD = 10L * 1024 * 1024;
private static final long CUSTOMS = 50L * 1024 * 1024; private static final long CUSTOMS = 50L * 1024 * 1024;
private static final long ORDER_IMPORT = 5L * 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.HexFormat;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
@Component @Component
@@ -27,6 +28,8 @@ public class BusinessFileStorage {
private static final Map<String, String> TYPES = Map.of( private static final Map<String, String> TYPES = Map.of(
"text/plain", ".txt", "application/pdf", ".pdf", "text/plain", ".txt", "application/pdf", ".pdf",
"image/jpeg", ".jpg", "image/png", ".png"); "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}") @Value("${baiyue.file.storage-root}")
private Path configuredRoot; private Path configuredRoot;
@@ -46,6 +49,7 @@ public class BusinessFileStorage {
return store(tenantId, businessId, file, category.maxSizeBytes(), category.formatPolicy()); return store(tenantId, businessId, file, category.maxSizeBytes(), category.formatPolicy());
} }
/** 按业务类别检查大小和格式,保存真实文件并返回存储结果。 */
private Stored store(long tenantId, String businessId, MultipartFile file, long maxSize, private Stored store(long tenantId, String businessId, MultipartFile file, long maxSize,
BusinessFileCategory.FormatPolicy formatPolicy) { BusinessFileCategory.FormatPolicy formatPolicy) {
if (file == null || file.isEmpty()) throw bad("附件不能为空"); if (file == null || file.isEmpty()) throw bad("附件不能为空");
@@ -55,6 +59,8 @@ public class BusinessFileStorage {
String extension; String extension;
if (formatPolicy == BusinessFileCategory.FormatPolicy.ARBITRARY) { if (formatPolicy == BusinessFileCategory.FormatPolicy.ARBITRARY) {
extension = ""; extension = "";
} else if (formatPolicy == BusinessFileCategory.FormatPolicy.CUSTOMER_VISIT) {
extension = customerVisitExtension(file);
} else if (formatPolicy == BusinessFileCategory.FormatPolicy.PDF) { } else if (formatPolicy == BusinessFileCategory.FormatPolicy.PDF) {
if (!"application/pdf".equals(type)) throw bad("附件仅支持PDF"); if (!"application/pdf".equals(type)) throw bad("附件仅支持PDF");
verifyContent(file, type); 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) { private static void verifyXlsx(MultipartFile file) {
try (InputStream input = file.getInputStream()) { try (InputStream input = file.getInputStream()) {
byte[] prefix = input.readNBytes(4); byte[] prefix = input.readNBytes(4);
@@ -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("归属不匹配");
}
}
@@ -87,11 +87,12 @@ public class PlatformFbaPricingController {
return ApiResponse.success(service.confirm(scope, user.userId(), inboundId, idempotencyKey)); return ApiResponse.success(service.confirm(scope, user.userId(), inboundId, idempotencyKey));
} }
/** 按费用发送权限发布已确认费用,审核门禁由服务端校验且不推进履约状态。 */
@PostMapping("/send") @PostMapping("/send")
@PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee'," @PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
+ " 'firstleg:inbound:send-fee')") + " 'firstleg:inbound:send-fee')")
@BusinessAudit(event = "FBA_FEE_SEND", resourceType = "INBOUND", resourceId = "#inboundId", @BusinessAudit(event = "FBA_FEE_SEND", resourceType = "INBOUND", resourceId = "#inboundId",
description = "发送FBA费用快照并进入待托盘状态") description = "发送FBA费用快照")
public ApiResponse<FeeSnapshotResponse> send( public ApiResponse<FeeSnapshotResponse> send(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey) { @PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey) {
@@ -100,8 +100,8 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
WHERE departure.container_booking_id=b.id WHERE departure.container_booking_id=b.id
AND departure.confirmed_at IS NOT NULL AND departure.reversed_at IS NULL AND departure.confirmed_at IS NOT NULL AND departure.reversed_at IS NULL
AND departure.status='WAITING_FOR_LOADING') AND departure.status='WAITING_FOR_LOADING')
AND ((#{query.scope}='ACTIVE' AND b.actual_empty_reported_at IS 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 b.actual_empty_reported_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}, '%') AND (#{query.keyword} IS NULL OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%') OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.bill_of_lading_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 ORDER BY inbound_detail_id
""") List<PalletLine> sealedPalletLines(@Param("palletId") long palletId); """) 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 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 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; } @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); return inbounds.update(null, update);
} }
/** 保存试算及计费量,保留测量、审核与仓库作业的主状态。 */
public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode, public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode,
java.math.BigDecimal selectedWeightKg, java.math.BigDecimal selectedWeightKg,
java.math.BigDecimal selectedVolumeCbm, java.math.BigDecimal selectedVolumeCbm,
@@ -341,10 +342,6 @@ public class FbaPricingRepository {
.eq(InboundPricingEntity::getProcessStatus, processStatus) .eq(InboundPricingEntity::getProcessStatus, processStatus)
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED", "SENT")) .in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED", "SENT"))
.set(InboundPricingEntity::getFeeStatus, "CALCULATED"); .set(InboundPricingEntity::getFeeStatus, "CALCULATED");
if (!List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")
.contains(processStatus)) {
update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING");
}
applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeMode, chargeable); applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeMode, chargeable);
return inbounds.update(null, update); return inbounds.update(null, update);
} }
@@ -383,12 +380,14 @@ public class FbaPricingRepository {
.set(InboundFeeEntity::getConfirmedAt, now)); .set(InboundFeeEntity::getConfirmedAt, now));
} }
/** 确认费用时保留物流主状态,一件代发待组托也允许确认。 */ /** 仅确认已通过测量审核的费用,并保留当前履约状态。 */
public int markInboundConfirmed(long tenantId, long inboundId) { public int markInboundConfirmed(long tenantId, long inboundId) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>() return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId) .eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
.in(InboundPricingEntity::getProcessStatus, .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") .eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED")); .set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
} }
@@ -436,15 +435,14 @@ public class FbaPricingRepository {
.set(InboundPricingEntity::getFeeStatus, "VOIDED")); .set(InboundPricingEntity::getFeeStatus, "VOIDED"));
} }
/** 发送仅更新费用状态,并原子校验测量已审核及履约状态未变化。 */
public int markInboundFeeSent(long tenantId, long inboundId, String processStatus) { public int markInboundFeeSent(long tenantId, long inboundId, String processStatus) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>() LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId) .eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
.eq(InboundPricingEntity::getProcessStatus, processStatus) .eq(InboundPricingEntity::getProcessStatus, processStatus)
.eq(InboundPricingEntity::getMeasureReviewStatus, "APPROVED")
.eq(InboundPricingEntity::getFeeStatus, "CONFIRMED") .eq(InboundPricingEntity::getFeeStatus, "CONFIRMED")
.set(InboundPricingEntity::getFeeStatus, "SENT"); .set(InboundPricingEntity::getFeeStatus, "SENT");
if ("FEE_CONFIRMING".equals(processStatus)) {
update.set(InboundPricingEntity::getProcessStatus, "WAIT_PALLET");
}
return inbounds.update(null, update); return inbounds.update(null, update);
} }
@@ -196,9 +196,8 @@ public class FbaPricingService {
} }
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now); feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
complete(token, fee.getId(), now); complete(token, fee.getId(), now);
String nextStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus()) // 费用是独立状态,提前试算不得覆盖待测量或待审核等履约进度。
? inbound.getProcessStatus() : "FEE_CONFIRMING"; return response(inbound, fee, rate.getUnitPrice(), inbound.getProcessStatus());
return response(inbound, fee, rate.getUnitPrice(), nextStatus);
} }
/** 价格未命中时按人工单价生成正式费用,计费量与只读预览保持一致。 */ /** 价格未命中时按人工单价生成正式费用,计费量与只读预览保持一致。 */
@@ -290,9 +289,7 @@ public class FbaPricingService {
weight, volume, quantity) != 1) throw conflict("入库单状态已变化,费用未保存"); weight, volume, quantity) != 1) throw conflict("入库单状态已变化,费用未保存");
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now); feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
complete(token, fee.getId(), now); complete(token, fee.getId(), now);
String nextStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus()) return response(inbound, fee, request.unitPrice(), inbound.getProcessStatus());
? inbound.getProcessStatus() : "FEE_CONFIRMING";
return response(inbound, fee, request.unitPrice(), nextStatus);
} }
/** 预览和正式计价共用价格匹配与柜级阶梯分摊,避免前端复制计价公式。 */ /** 预览和正式计价共用价格匹配与柜级阶梯分摊,避免前端复制计价公式。 */
@@ -577,10 +574,14 @@ public class FbaPricingService {
public record ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus status, public record ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus status,
int recalculatedCount) { } int recalculatedCount) { }
/** 测量审核通过后才允许确认费用,避免提前冻结费用阻断审核后的重新计价。 */
@Transactional @Transactional
public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId, public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId,
String idempotencyKey) { String idempotencyKey) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId); InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) {
throw conflict("测量数据尚未审核通过,请先完成测量审核后再确认费用");
}
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) { if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
throw conflict("当前入库单状态不允许确认费用"); throw conflict("当前入库单状态不允许确认费用");
} }
@@ -599,10 +600,14 @@ public class FbaPricingService {
return response(tenantId, current.getId(), null); return response(tenantId, current.getId(), null);
} }
/** 仅发布已通过测量审核的费用,发送不改变测量与仓库作业进度。 */
@Transactional @Transactional
public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId, public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId,
String idempotencyKey) { String idempotencyKey) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId); InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) {
throw conflict("测量数据尚未审核通过,请先完成测量审核后再发送客户");
}
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) { if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
throw conflict("当前入库单状态不允许发送费用"); throw conflict("当前入库单状态不允许发送费用");
} }
@@ -611,8 +616,7 @@ public class FbaPricingService {
String hash = hash("SEND", inboundId, current.getId().toString()); String hash = hash("SEND", inboundId, current.getId().toString());
InboundPricingIdempotencyEntity token = beginIdempotency( InboundPricingIdempotencyEntity token = beginIdempotency(
tenantId, operatorId, inboundId, "FEE_SEND", idempotencyKey, hash); tenantId, operatorId, inboundId, "FEE_SEND", idempotencyKey, hash);
String nextProcessStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus()) String nextProcessStatus = inbound.getProcessStatus();
? inbound.getProcessStatus() : "WAIT_PALLET";
if ("COMPLETED".equals(token.getStatus())) { if ("COMPLETED".equals(token.getStatus())) {
feeDetails.publishFreight(tenantId, inboundId, token.getResultResourceId(), feeDetails.publishFreight(tenantId, inboundId, token.getResultResourceId(),
operatorId, null, LocalDateTime.now(clock)); operatorId, null, LocalDateTime.now(clock));
@@ -812,11 +816,6 @@ public class FbaPricingService {
return inbound; 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) { private static boolean feeRevisionStatusSupported(String processStatus) {
return List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", return List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
"WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING",
@@ -169,24 +169,23 @@ public class LoadingCompletedOutboxService {
traceId, eventKey, null, payloadNode, hash).requireValid(); traceId, eventKey, null, payloadNode, hash).requireValid();
} }
/** 国内同类照片可追加,交接按每类最新有效照片冻结一张对照图。 */
private List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> loadingEvidence(Long containerBookingId) { private List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> loadingEvidence(Long containerBookingId) {
if (containerBookingId == null || containerBookingId <= 0) { if (containerBookingId == null || containerBookingId <= 0) {
throw new BizException(ResultCode.CONFLICT, "装柜计划缺少柜次,不能冻结装柜照片"); throw new BizException(ResultCode.CONFLICT, "装柜计划缺少柜次,不能冻结装柜照片");
} }
List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> result = cabinetEvidenceMapper Map<String, FirstLegLoadingPlanCompletedV3.LoadingEvidence> latestByType = new java.util.TreeMap<>();
.byContainer(containerBookingId).stream() // Mapper 按类型、证据 ID 升序返回;同类后到的有效照片覆盖前一张,原始证据全部保留。
cabinetEvidenceMapper.byContainer(containerBookingId).stream()
.filter(value -> Integer.valueOf(1).equals(value.getActiveFlag()) && "ACTIVE".equals(value.getStatus())) .filter(value -> Integer.valueOf(1).equals(value.getActiveFlag()) && "ACTIVE".equals(value.getStatus()))
.map(value -> new FirstLegLoadingPlanCompletedV3.LoadingEvidence( .forEach(value -> latestByType.put(value.getEvidenceType(),
value.getEvidenceType(), value.getFileId(), value.getFileName())) new FirstLegLoadingPlanCompletedV3.LoadingEvidence(
.sorted(Comparator.comparing(FirstLegLoadingPlanCompletedV3.LoadingEvidence::evidenceType)) value.getEvidenceType(), value.getFileId(), value.getFileName())));
.toList(); if (!latestByType.keySet().equals(Set.of(
Set<String> types = result.stream().map(FirstLegLoadingPlanCompletedV3.LoadingEvidence::evidenceType)
.collect(java.util.stream.Collectors.toSet());
if (result.size() != 5 || !types.equals(Set.of(
"PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL"))) { "PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL"))) {
throw new BizException(ResultCode.CONFLICT, "国内装柜五类照片不完整,不能生成尾程交接事件"); throw new BizException(ResultCode.CONFLICT, "国内装柜五类照片不完整,不能生成尾程交接事件");
} }
return result; return List.copyOf(latestByType.values());
} }
private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows, private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows,
@@ -53,6 +53,8 @@ public class PdaPickSettlementService {
private InboundHoldGuard inboundHoldGuard; private InboundHoldGuard inboundHoldGuard;
@Resource @Resource
private DropshipPalletizationMapper dropshipPallets; private DropshipPalletizationMapper dropshipPallets;
@Resource
private com.baiyue.firstleg.repository.DropshipSplitRepository dropshipSplits;
@Value("${baiyue.security.internal-service-token}") @Value("${baiyue.security.internal-service-token}")
private String internalToken; private String internalToken;
@@ -121,6 +123,7 @@ public class PdaPickSettlementService {
} }
} }
/** 按柜次实拣数量结算,一件代发保留未装整托供后续柜次使用。 */
private boolean settleInbound(long parentInboundId, int pickedPackageCount, private boolean settleInbound(long parentInboundId, int pickedPackageCount,
List<LoadingPickItemEntity> taskItems, long taskId, List<LoadingPickItemEntity> taskItems, long taskId,
long operatorId, String operatorName) { long operatorId, String operatorName) {
@@ -184,9 +187,7 @@ public class PdaPickSettlementService {
&& pickedByDetail.values().stream().mapToInt(Integer::intValue).sum() != pickedPackageCount) { && pickedByDetail.values().stream().mapToInt(Integer::intValue).sum() != pickedPackageCount) {
throw conflict("PDA 已拣明细与入库单已拣箱数不一致"); throw conflict("PDA 已拣明细与入库单已拣箱数不一致");
} }
if (dropshipWholePallet && childPackageCount != 0) {
throw conflict("一件代发现场托盘必须整票装柜,不能生成余量分片");
}
List<MeasurementSplitLine> measurementLines = dropshipWholePallet List<MeasurementSplitLine> measurementLines = dropshipWholePallet
? wholePalletMeasurementLines(measurements, pickedPackageCount, parentAvailable) ? wholePalletMeasurementLines(measurements, pickedPackageCount, parentAvailable)
: measurementLines(measurements, pickedByDetail); : measurementLines(measurements, pickedByDetail);
@@ -219,8 +220,17 @@ public class PdaPickSettlementService {
requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), parentInboundId, rootInboundId, remainderChildId, requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), parentInboundId, rootInboundId, remainderChildId,
orderSplit.remainderChild().orderId(), orderSplit.remainderChild().orderNo(), orderSplit.remainderChild().orderId(), orderSplit.remainderChild().orderNo(),
remainderChildCode, childPackageCount, sequence, "REMAINDER", operatorId, operatorName), "余量子入库单"); remainderChildCode, childPackageCount, sequence, "REMAINDER", operatorId, operatorName), "余量子入库单");
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, splits.transferRemainingCartonLabels(parent.tenantId(), parentInboundId, remainderChildId,
orderSplit.remainderChild().orderId(), remainderChildCode, pickedPackageCount, childPackageCount); orderSplit.remainderChild().orderId(), remainderChildCode, pickedPackageCount, childPackageCount);
}
splits.normalizeLoadedCartonLabels(parent.tenantId(), parentInboundId, pickedPackageCount); splits.normalizeLoadedCartonLabels(parent.tenantId(), parentInboundId, pickedPackageCount);
} }
requireAffected(splits.insertBatchWithChildren(batchId, parent.tenantId(), rootInboundId, operationId, parentInboundId, requireAffected(splits.insertBatchWithChildren(batchId, parent.tenantId(), rootInboundId, operationId, parentInboundId,
@@ -408,17 +418,24 @@ public class PdaPickSettlementService {
return result; return result;
} }
/** 一件代发逐箱封托已经冻结 SKU 归属;测量批次只承担整票体积重量,不再冒充 SKU 明细。 */ /** 整托箱数守恒,测量行按箱数分摊重量体积;真实SKU组成仍由封托箱账本冻结。 */
private static List<MeasurementSplitLine> wholePalletMeasurementLines(List<MeasurementItemRow> measurements, private static List<MeasurementSplitLine> wholePalletMeasurementLines(List<MeasurementItemRow> measurements,
int pickedPackageCount, int pickedPackageCount,
int parentAvailable) { int parentAvailable) {
int measuredPackages = measurements.stream() int measuredPackages = measurements.stream()
.mapToInt(item -> count(item.packageCount(), "测量明细箱数")).sum(); .mapToInt(item -> count(item.packageCount(), "测量明细箱数")).sum();
if (pickedPackageCount != parentAvailable || measuredPackages != parentAvailable) { if (pickedPackageCount <= 0 || pickedPackageCount > parentAvailable || measuredPackages != parentAvailable) {
throw conflict("一件代发整托箱数与入库单、测量批次不守恒"); throw conflict("一件代发整托箱数与入库单、测量批次不守恒");
} }
return measurements.stream().map(item -> new MeasurementSplitLine(item, int left = pickedPackageCount;
count(item.packageCount(), "测量明细箱数"), 0)).toList(); 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) { private static Map<Long, Integer> measurementQuantities(List<MeasurementSplitLine> lines) {
@@ -72,7 +72,7 @@ public class SplitPreloadService {
@Transactional @Transactional
public void ensurePendingForLoadingAllocation(long tenantId, long inboundId, long operatorId, public void ensurePendingForLoadingAllocation(long tenantId, long inboundId, long operatorId,
String operatorName) { 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. */ /** Creates a provisional preload lifecycle for an order selected before it reaches WAIT_SPLIT. */
@@ -113,16 +113,17 @@ public class SplitPreloadService {
@Transactional @Transactional
public void ensureForRunningLoadingAllocation(long tenantId, long inboundId, long operatorId, public void ensureForRunningLoadingAllocation(long tenantId, long inboundId, long operatorId,
String operatorName) { String operatorName) {
ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, true); ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName);
} }
/** 复用既有预装计划;同一入仓单其他未分配整托可继续进入不同柜次。 */
private void ensureLoadingAllocationLifecycle(long tenantId, long inboundId, long operatorId, private void ensureLoadingAllocationLifecycle(long tenantId, long inboundId, long operatorId,
String operatorName, boolean running) { String operatorName) {
InboundSnapshot inbound = requireInbound(tenantId, inboundId); InboundSnapshot inbound = requireInbound(tenantId, inboundId);
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId); PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
boolean waitingSplit = isWaitingSplit(inbound.processStatus()); boolean waitingSplit = isWaitingSplit(inbound.processStatus());
boolean loading = "LOADING".equals(inbound.processStatus()); boolean loading = "LOADING".equals(inbound.processStatus());
if (!waitingSplit && !(running && loading)) { if (!waitingSplit && !loading) {
throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以建立智能装柜预装计划"); throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以建立智能装柜预装计划");
} }
if (loading && plan == null) { 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("费用确认事实不属于本批入仓来源"); if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable("费用确认事实不属于本批入仓来源");
result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee); 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<>(); Map<String,WorkflowFacts.Overseas> overseas=new HashMap<>();
if(!sources.isEmpty()) { if(!sources.isEmpty()) {
try { try {
@@ -40,8 +42,10 @@ public class WorkflowTabService {
if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable("尾程事实包含空记录或本批以外的客户/入仓来源"); 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())); 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) { } catch(BizException e) { throw e; } catch(Exception e) {
throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试").initCause(e); throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试").initCause(e);
} }
@@ -61,6 +65,11 @@ public class WorkflowTabService {
Set<String> keys=new LinkedHashSet<>(); Set<String> keys=new LinkedHashSet<>();
if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW"); if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW");
boolean signed="DELIVERED".equals(process); 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(); boolean clearanceStage=f.departed();
if(!clearanceStage && !signed) { if(!clearanceStage && !signed) {
@@ -75,8 +84,6 @@ public class WorkflowTabService {
if(!o.outboundExists() && !o.unloaded()) keys.add("WAIT_UNLOAD"); if(!o.outboundExists() && !o.unloaded()) keys.add("WAIT_UNLOAD");
// 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。 // 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。
if(!o.outboundExists()) keys.add("WAIT_DISPATCH"); if(!o.outboundExists()) keys.add("WAIT_DISPATCH");
if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT");
if(o.delivering()) keys.add("DELIVERING");
} }
} }
return keys; return keys;
@@ -171,13 +171,13 @@ paths:
/api/platform/v1/inbounds/{inboundId}/fees/confirm: /api/platform/v1/inbounds/{inboundId}/fees/confirm:
post: post:
summary: 确认最新费用快照 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'}] 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: 无权限}} responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/send: /api/platform/v1/inbounds/{inboundId}/fees/send:
post: post:
summary: 发送已确认费用快照 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'}] 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: 无权限}} responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/latest: /api/platform/v1/inbounds/{inboundId}/fees/latest:
@@ -1679,7 +1679,7 @@ components:
type: string 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] 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 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} customerId: {type: integer, format: int64, nullable: true}
receivingWarehouseId: {type: integer, format: int64, nullable: true} receivingWarehouseId: {type: integer, format: int64, nullable: true}
destinationWarehouseId: {type: integer, format: int64, nullable: true} destinationWarehouseId: {type: integer, format: int64, nullable: true}
@@ -28,6 +28,29 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("货柜数据库映射契约") @DisplayName("货柜数据库映射契约")
class ContainerBookingMapperContractTest { 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 @Test
@DisplayName("货柜映射平台级最终表且状态只从装柜计划读取") @DisplayName("货柜映射平台级最终表且状态只从装柜计划读取")
void mapsFinalBookingContract() throws Exception { 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(); session.getConnection();
sql = new JdbcTemplate(ds); sql = new JdbcTemplate(ds);
sql.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT," + 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(); repository = new FbaPricingRepository();
ReflectionTestUtils.setField(repository, "inbounds", session.getMapper(InboundPricingMapper.class)); ReflectionTestUtils.setField(repository, "inbounds", session.getMapper(InboundPricingMapper.class));
} }
@@ -50,7 +51,8 @@ class FbaPricingFeeTransitionTest {
/** 费用确认和发送不得提前完成一件代发组托或回退已有履约进度。 */ /** 费用确认和发送不得提前完成一件代发组托或回退已有履约进度。 */
@ParameterizedTest @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) { void confirmsAndSendsWithoutAdvancingPalletization(String processStatus) {
insertInbound(processStatus, "CALCULATED"); insertInbound(processStatus, "CALCULATED");
@@ -58,7 +60,7 @@ class FbaPricingFeeTransitionTest {
assertState(processStatus, "CONFIRMED"); assertState(processStatus, "CONFIRMED");
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero(); assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isEqualTo(1); 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(); assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isZero();
} }
@@ -71,12 +73,10 @@ class FbaPricingFeeTransitionTest {
assertState("WAIT_PALLETIZE", feeStatus); assertState("WAIT_PALLETIZE", feeStatus);
} }
/** 非确认阶段、其他客户和已删除入库单仍不可确认。 */ /** 其他客户和已删除入库单仍不可确认。 */
@Test @Test
void preservesProcessCustomerAndDeletionGuards() { void preservesProcessCustomerAndDeletionGuards() {
insertInbound("WAIT_MEASURE", "CALCULATED"); insertInbound("WAIT_PALLETIZE", "CALCULATED");
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
sql.update("UPDATE fl_inbound_order SET process_status='WAIT_PALLETIZE' WHERE id=88");
assertThat(repository.markInboundConfirmed(20002L, 88L)).isZero(); assertThat(repository.markInboundConfirmed(20002L, 88L)).isZero();
sql.update("UPDATE fl_inbound_order SET deleted=TRUE WHERE id=88"); sql.update("UPDATE fl_inbound_order SET deleted=TRUE WHERE id=88");
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero(); assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
@@ -91,6 +91,64 @@ class FbaPricingFeeTransitionTest {
assertState("WAIT_SPLIT", "CONFIRMED"); 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) { private void insertInbound(String processStatus, String feeStatus) {
sql.update("INSERT INTO fl_inbound_order(id,tenant_id,process_status,fee_status) VALUES(88,10001,?,?)", sql.update("INSERT INTO fl_inbound_order(id,tenant_id,process_status,fee_status) VALUES(88,10001,?,?)",
@@ -165,6 +165,7 @@ class FbaPricingServiceTest {
} }
@Test @Test
/** 试算生成费用但保留原履约进度。 */
@DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商") @DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商")
void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() { void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() {
readyRepository(); readyRepository();
@@ -186,7 +187,7 @@ class FbaPricingServiceTest {
assertThat(response.conversionFactor()).isEqualByComparingTo("167"); assertThat(response.conversionFactor()).isEqualByComparingTo("167");
assertThat(response.roundingScale()).isEqualTo(6); assertThat(response.roundingScale()).isEqualTo(6);
assertThat(response.roundingMode()).isEqualTo("HALF_UP"); 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); ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository).insertFee(fee.capture()); verify(repository).insertFee(fee.capture());
assertThat(fee.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION"); assertThat(fee.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION");
@@ -630,7 +631,7 @@ class FbaPricingServiceTest {
/** 待组托发送费用保留主状态,重复请求不重复生成费用事件。 */ /** 待组托发送费用保留主状态,重复请求不重复生成费用事件。 */
@ParameterizedTest @ParameterizedTest
@CsvSource({"FEE_CONFIRMING,WAIT_PALLET", "WAIT_PALLETIZE,WAIT_PALLETIZE"}) @CsvSource({"FEE_CONFIRMING,FEE_CONFIRMING", "WAIT_PALLETIZE,WAIT_PALLETIZE"})
@DisplayName("重复发送只返回首次结果且不会重复写Outbox") @DisplayName("重复发送只返回首次结果且不会重复写Outbox")
void sendIsIdempotentAndDoesNotDuplicateOutbox(String processStatus, String expectedStatus) { void sendIsIdempotentAndDoesNotDuplicateOutbox(String processStatus, String expectedStatus) {
InboundPricingEntity inbound = inbound("10", "1"); InboundPricingEntity inbound = inbound("10", "1");
@@ -687,6 +688,39 @@ class FbaPricingServiceTest {
eq(TENANT), eq(INBOUND), eq(501L), eq(1), eq(NOW)); 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 @Test
@DisplayName("同一幂等键用于不同试算时点会冲突") @DisplayName("同一幂等键用于不同试算时点会冲突")
void rejectsReusedIdempotencyKeyWithDifferentRequest() { void rejectsReusedIdempotencyKeyWithDifferentRequest() {
@@ -101,6 +101,61 @@ class LoadingCompletedOutboxServiceTest {
assertThat(first.eventVersion()).isEqualTo(3); 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 事件,并保持冻结柜号原值。 */ /** 非 ISO 柜号能生成当前 V3 事件,并保持冻结柜号原值。 */
@org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "LOCAL-2026-001"}) @org.junit.jupiter.params.provider.ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "LOCAL-2026-001"})
@@ -46,6 +46,7 @@ class PdaPickSettlementServiceTest {
@MockBean private OrderFulfillmentSplitClient orderSplits; @MockBean private OrderFulfillmentSplitClient orderSplits;
@MockBean private InboundHoldGuard inboundHoldGuard; @MockBean private InboundHoldGuard inboundHoldGuard;
@MockBean private DropshipPalletizationMapper dropshipPallets; @MockBean private DropshipPalletizationMapper dropshipPallets;
@MockBean private com.baiyue.firstleg.repository.DropshipSplitRepository dropshipSplits;
@Resource private PdaPickSettlementService service; @Resource private PdaPickSettlementService service;
@Test @Test
@@ -266,6 +267,29 @@ class PdaPickSettlementServiceTest {
&& request.lines().size() == 1 && request.lines().get(0).packageCount() == 20)); && 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 @Test
void onlyPureSkippedInboundRestoresCandidate() { void onlyPureSkippedInboundRestoresCandidate() {
LoadingPickItemEntity item = item(12L, 22L, 32L, 42L, 8, 0, "SKIPPED"); LoadingPickItemEntity item = item(12L, 22L, 32L, 42L, 8, 0, "SKIPPED");
@@ -148,14 +148,13 @@ class SplitPreloadServiceTest {
} }
@Test @Test
void ordinaryLoadingAllocationStillRequiresWaitingSplit() { void otherCabinetMayReuseExistingPreloadWhileSourceIsLoading() {
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3)); when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3));
PreloadPlanEntity plan = new PreloadPlanEntity(); PreloadPlanEntity plan = new PreloadPlanEntity();
plan.setStatus("PENDING"); plan.setStatus("PENDING");
when(repository.findPlan(10, 20)).thenReturn(plan); when(repository.findPlan(10, 20)).thenReturn(plan);
assertThatThrownBy(() -> service.ensurePendingForLoadingAllocation(10, 20, 7, "admin")) service.ensurePendingForLoadingAllocation(10, 20, 7, "admin");
.hasMessageContaining("仅待分柜订单");
verify(repository, never()).createPlan(any()); verify(repository, never()).createPlan(any());
} }
@@ -36,7 +36,7 @@ class WorkflowBoundaryTest {
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure); assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure);
} }
/** 报关完成或提前上传资料仍留在出口报关,实际开船后才查询精确海外来源。 */ /** 未开船也查询尾程建单事实,未产生尾程来源时仍保留国内阶段。 */
@Test void completedCustomsAndClearanceUploadWaitForActualDeparture() { @Test void completedCustomsAndClearanceUploadWaitForActualDeparture() {
var repository=mock(WorkflowFactRepository.class); var repository=mock(WorkflowFactRepository.class);
var downstream=mock(LastlegWorkflowClient.class); var downstream=mock(LastlegWorkflowClient.class);
@@ -45,12 +45,15 @@ class WorkflowBoundaryTest {
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED"); inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
when(repository.facts(List.of(1L))).thenReturn(List.of( when(repository.facts(List.of(1L))).thenReturn(List.of(
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true))); 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"); 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( when(repository.facts(List.of(1L))).thenReturn(List.of(
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,true))); 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"); 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( when(repository.facts(List.of(1L))).thenReturn(List.of(
new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true,true))); new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true,true)));
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of( 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))))); 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)) assertThat(WorkflowTabService.classify(uploaded,"DELIVERED",unloaded))
.contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS"); .contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS");
} }
/** 清关前不能被海外建单事实提前推进,清关后出库与派送依次替换待办。 */ /** 出库与派送按尾程事实展示,不受清关资料限制。 */
@Test void downstreamFactsCannotBypassClearanceAndOutboundReplacesQueues() { @Test void downstreamFactsCannotBypassClearanceAndOutboundReplacesQueues() {
var pending=new WorkflowFacts.Overseas(11,1,true,true,false,false); var pending=new WorkflowFacts.Overseas(11,1,true,true,false,false);
var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,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",pending)).containsExactly("OUTBOUND_DOCUMENT","SAILING_CLEARANCE");
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",shipped)).containsExactly("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",pending)).containsExactly("OUTBOUND_DOCUMENT");
assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",shipped)).containsExactly("DELIVERING"); assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",shipped)).containsExactly("DELIVERING");
assertThat(WorkflowTabService.classify(fact(1,true),"DELIVERED",shipped)).containsExactly("SIGNED"); assertThat(WorkflowTabService.classify(fact(1,true),"DELIVERED",shipped)).containsExactly("SIGNED");
@@ -11,8 +11,8 @@ public interface DispatchWholePdaReadMapper {
record Picked(Long itemId,long packages) { } record Picked(Long itemId,long packages) { }
record Invalid(Long dispatchId,long quantity) { } record Invalid(Long dispatchId,long quantity) { }
record Carton(Long itemId,Long cartonId,String cartonMark,String status,long productQuantity) { } 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>" +"<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>") +"<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); 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, List<DispatchDeliveryModels.DeliveryReference> deliveries,
Instant createdAt, Instant updatedAt, String remark) { } Instant createdAt, Instant updatedAt, String remark) { }
/** 散板按客户和商业订单汇总;来源键集合仅供只读明细关联,不替代作业来源身份。 */
public record OrderDetail(String customerId, String customerName, String orderNo, String inboundCode, public record OrderDetail(String customerId, String customerName, String orderNo, String inboundCode,
BigDecimal grossWeightKg, Integer palletCount, String destinationFbaWarehouse, BigDecimal grossWeightKg, Integer palletCount, String destinationFbaWarehouse,
BigDecimal volumeCbm, String containerNo, String sourceKey, 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, public record Detail(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion,
@@ -6,11 +6,12 @@ import java.util.List;
/** 客户照片仅查询已核准客户与原单的有效承运关联。 */ /** 客户照片仅查询已核准客户与原单的有效承运关联。 */
@Repository @Repository
public class MerchantTransportPhotoRepository { public class MerchantTransportPhotoRepository implements com.baiyue.lastleg.looseboard.port.LooseboardDispatches {
private final MerchantTransportPhotoMapper mapper; private final MerchantTransportPhotoMapper mapper;
/** 注入本域查询入口。 */ /** 注入本域查询入口。 */
public MerchantTransportPhotoRepository(MerchantTransportPhotoMapper mapper){this.mapper=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> looseboardDispatches(String customer,String number){return mapper.looseboardDispatches(customer,number);}
/** 返回原卡车单有效运输任务。 */ /** 返回原卡车单有效运输任务。 */
public List<String> truckDispatches(String customer,String number){return mapper.truckDispatches(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( 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 -> { 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); 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(), return DispatchDestinationAppointments.booked(b,confirmed);
confirmed==null?"PENDING":"SUCCESS",confirmed==null?null:confirmed.startAt(),confirmed==null?"尚无当前有效收货确认":null,b.customerId(),List.of());
}).toList(); }).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)); 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) { 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()); 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) { for(var item:cargo) {
var source=read(item.getSourceSnapshotJson(),SourceDisplay.class); var source=read(item.getSourceSnapshotJson(),SourceDisplay.class);
@@ -1240,11 +1239,46 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(orders.isEmpty()) orders=Collections.singletonList(null); if(orders.isEmpty()) orders=Collections.singletonList(null);
for(var order:orders) { for(var order:orders) {
var business=looseboardDisplay.find(businessSources,item.getCustomerId(),order,item.getPlanPalletId()); 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, 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())); 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权威资源、本趟照片和维护资料,不写入车辆或司机主档。 */ /** 合并Fleet权威资源、本趟照片和维护资料,不写入车辆或司机主档。 */
@@ -1308,14 +1342,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
public long pickedRemotePackages(DispatchAggregate row){return looseboardFacts==null?0:looseboardFacts.pickedRemotePackages(row);} 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 List<DispatchItem> warehouseItems(DispatchAggregate row){return looseboardFacts==null?repository.cargo(row.getId()):looseboardFacts.warehouseItems(row.getId());}
/** 异地取货仍占用本趟容量;使用仓内实测加散板申报,不补造实托或实测。 */ /** 混排核对已知占用;未知重量体积不阻塞仓内出库,也不补造实测。 */
public void requireRemoteCapacity(DispatchAggregate row,List<LoadingMeasurement> measured){ public void requireRemoteCapacity(DispatchAggregate row,List<LoadingMeasurement> measured){
var ids=remoteItemIds(row);if(ids.isEmpty())return; var ids=remoteItemIds(row);if(ids.isEmpty())return;
var remote=repository.cargo(row.getId()).stream().filter(i->ids.contains(i.getId())).toList(); var remote=repository.cargo(row.getId()).stream().filter(i->ids.contains(i.getId())).toList();
var capacity=read(row.getResourcesJson(),Resources.class).vehicle().capacity(); 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 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).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 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("仓内装载与异地取货合计超过本车容量"); 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()); if (maintenance != null) for (var entry : maintenance.destinationAppointments()) entered.put(entry.destinationKey(),entry.appointmentNo());
var result = new ArrayList<FbaAppointment>(); var result = new ArrayList<FbaAppointment>();
destinations.forEach((key,code) -> { 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 numbers = confirmed.stream().map(FbaAppointment::appointmentNo).distinct().toList();
// 草稿暂未填写不能遮挡后来取得的唯一正式确认号,已填写值仍保留。 // 草稿暂未填写不能遮挡后来取得的唯一正式确认号,已填写值仍保留。
var enteredNumber = entered.get(key); var enteredNumber = entered.get(key);
var number = enteredNumber != null ? enteredNumber : numbers.size() == 1 ? numbers.get(0) : null; 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 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(); 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, result.add(new FbaAppointment(null,null,number,key,code,number == null ? "PENDING" : at == null ? "REGISTERED" : "SUCCESS",at,
number == null ? "请填写目的仓 " + code + " 的预约号" : null,null,shipments)); number == null ? "请填写目的仓 " + code + " 的预约号" : null,null,shipments));
@@ -40,6 +40,15 @@ final class DispatchDestinationAppointments {
return List.copyOf(result); 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) { static void validate(Maintenance maintenance, List<FbaAppointment> destinations) {
if (maintenance == null) return; if (maintenance == null) return;
@@ -57,7 +57,7 @@ public class DispatchLooseboardExecutionService {
if(!(phase==Phase.PICKUP?item.canPickup():item.canArrive()))throw conflict("当前阶段不能上传该节点照片"); if(!(phase==Phase.PICKUP?item.canPickup():item.canArrive()))throw conflict("当前阶段不能上传该节点照片");
return files.upload(required(number).getId(),kind(phase),actor.operatorId(),file); return files.upload(required(number).getId(),kind(phase),actor.operatorId(),file);
} }
/** 父锁、公共版本、不可变命令和照片推进在同一事务保存;失败不留下假发车。 */ /** 同客户散板原单的各托照片原子保存,公共版本与命令身份仍由整车统一控制。 */
@Transactional(isolation=Isolation.READ_COMMITTED) @Transactional(isolation=Isolation.READ_COMMITTED)
public Detail save(Actor actor,String number,long itemId,Phase phase,Save request){ public Detail save(Actor actor,String number,long itemId,Phase phase,Save request){
workflow.lockForArrangement(actor,number);var row=required(number); workflow.lockForArrangement(actor,number);var row=required(number);
@@ -69,7 +69,9 @@ public class DispatchLooseboardExecutionService {
} }
if(row.getRecordVersion().longValue()!=request.recordVersion())throw conflict("排车版本已变化,请刷新后重试"); 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 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(!(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张不重复照片"); 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(); 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("已确认取货或送达的节点至少保留一张凭证,可先追加新照片再删除旧照片"); 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()); 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 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()); 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->!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("照片已绑定其他节点或明细,不能重复使用"); 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(); 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("取货和送达凭证必须为照片"); if(photos.stream().anyMatch(f->!Set.of("image/jpeg","image/png").contains(f.contentType())))throw conflict("取货和送达凭证必须为照片");
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS); 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)); 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.saveState(row,original)!=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(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(phase==Phase.ARRIVAL && !photos.isEmpty()){
if(!arrivalRecorded)orders.progress(cargo.item().getCustomerId(),cargo.orderNo(),actor.operatorId(),facts.deliveredPackages(cargo.item().getCustomerId(),cargo.orderNo())); 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)){ 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()); 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())); 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); }).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;} 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) @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchViews.PdaTask task(Actor actor,String number) { public DispatchViews.PdaTask task(Actor actor,String number) {
require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号"); 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不推算实收仓位或箱数。 */ /** 按收货已确认的实托批量读取;未交接的原PLAN不推算实收仓位或箱数。 */
private List<DispatchViews.PdaTask> assemble(Actor actor,List<DispatchAggregate> roots) { 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(); var consumed=hasRelabel?sourceOrigins.pickedAll(ids).stream().collect(Collectors.toMap(p->p.sourceId(),p->p.packages())):Map.<Long,Long>of();
for(var root:roots) { 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>(); 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>(); boolean sourceValid=true;var expectedHold=new HashMap<String,Long>();
for(var source:frozen)if(Objects.equals(source.getDispatchOrderId(),root.getId())) { for(var source:frozen)if(Objects.equals(source.getDispatchOrderId(),root.getId())) {
long used=consumed.getOrDefault(source.getId(),0L),left=n(source.getPackageCount())-used; 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.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.*;
import java.util.Set; import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*;
/** 已发布日常派送排车复用正式加货命令,不创建第二车次或库存。 */ /** 已发布日常派送排车复用正式加货命令,不创建第二车次或库存。 */
@Service @Service
@@ -16,8 +16,10 @@ public class LooseboardCargoService {
private final LooseboardCargoRepository cargo; private final LooseboardCargoRepository cargo;
private final DispatchAggregateRepository dispatches; private final DispatchAggregateRepository dispatches;
private final DispatchAggregateService service; private final DispatchAggregateService service;
public LooseboardCargoService(LooseboardCargoRepository cargo, DispatchAggregateRepository dispatches, DispatchAggregateService service) { private final DispatchCommandHasher hashes;
this.cargo=cargo; this.dispatches=dispatches; this.service=service; /** 加货候选复用权威来源及版本摘要,不在客户端拼造来源。 */
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) { private String warehouse(String number) {
var row=dispatches.find(number); var row=dispatches.find(number);
@@ -25,12 +27,37 @@ public class LooseboardCargoService {
throw new ResponseStatusException(HttpStatus.CONFLICT,"该排车已退出散板需求池,请刷新列表"); throw new ResponseStatusException(HttpStatus.CONFLICT,"该排车已退出散板需求池,请刷新列表");
return row.getOverseasWarehouseId(); return row.getOverseasWarehouseId();
} }
/** 按完整散板订单分页;同单各托组成一次选择,不能因托盘跨页漏选。 */
@Transactional(readOnly=true) @Transactional(readOnly=true)
public DispatchPlanningModels.CandidateList candidates(DispatchContracts.Actor actor,String number,int page,int size) { public DispatchPlanningModels.CandidateList candidates(DispatchContracts.Actor actor,String number,int page,int size) {
String warehouse=warehouse(number); var plans=cargo.readyPlans(warehouse); 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.isEmpty())return new DispatchPlanningModels.CandidateList(List.of(),Instant.now(),true,0,page,size,0);
if(plans.size()>500)throw new ResponseStatusException(HttpStatus.CONFLICT,"同仓待排散板过多,请先在排车工作台处理"); 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 @Transactional
public DispatchContracts.CommandResult add(DispatchContracts.Actor actor,String number,DispatchCommands.AddCargo request) { 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") @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); 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") @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); 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 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); } 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(candidates.isEmpty())throw new ResponseStatusException(HttpStatus.CONFLICT,"本车没有可关联的派送来源");
if(warehouse) { if(warehouse) {
var seen=new HashSet<String>(); var seen=new HashSet<String>();
var grouped=new LinkedHashMap<Candidate,List<ExceptionOrderRequests.MarkReturn>>();
for(int index=0;index<selected.size();index++) { for(int index=0;index<selected.size();index++) {
var selection=selected.get(index); var selection=selected.get(index);
if(selection==null || selection.items()==null || selection.items().isEmpty() || selection.items().size()>200 || !seen.add(selection.deliveryNo())) if(selection==null || selection.items()==null || selection.items().isEmpty() || selection.items().size()>200
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"每张派送单请选择一张来源订单及有效退回货物"); || !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() var source=candidates.stream().filter(c->Objects.equals(c.deliveryNo(),selection.deliveryNo()) && Objects.equals(c.lastMileOrderId(),selection.lastMileOrderId())).findFirst()
.orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"所选来源不属于当前排车")); .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,"退回明细无效"); 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(), 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)); "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, } 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)); 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(), ExceptionOrderResponses.Relation relation = relation(scopedCustomerId, request.customerId(),
request.inboundCode(), request.lastMileOrderNo()); request.inboundCode(), request.lastMileOrderNo());
return insert(relation, null, normalizeType(request.exceptionType()), request.reason(), request.packageCount(), 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()); ExceptionOrderCommand replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim());
if (replay != null) return replayCreated(replay, requestHash); if (replay != null) return replayCreated(replay, requestHash);
DeliveryOrder delivery = lockForReturn(customerId, deliveryNo); 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); List<ExceptionOrderResponses.Relation> candidates = returnCandidates(delivery);
ExceptionOrderResponses.Relation selected = candidates.stream() ExceptionOrderResponses.Relation selected = candidates.stream()
.filter(candidate -> Objects.equals(candidate.lastMileOrderId(), request.lastMileOrderId())) .filter(candidate -> Objects.equals(candidate.lastMileOrderId(), request.lastMileOrderId()))
@@ -248,7 +285,7 @@ public class ExceptionOrderService {
} }
ExceptionOrderResponses.Created created = insert(selected, delivery, "RETURN", request.reason(), ExceptionOrderResponses.Created created = insert(selected, delivery, "RETURN", request.reason(),
packages, sku, returnDirection, request.returnLogisticsNo(), packages, sku, returnDirection, request.returnLogisticsNo(),
"DELIVERY_RETURN", operatorId, operatorName); "DELIVERY_RETURN", operatorId, operatorName, originalDeliveryStatus);
for (ExceptionOrderRequests.ReturnItem requestedItem : requested) { for (ExceptionOrderRequests.ReturnItem requestedItem : requested) {
ExceptionOrderResponses.ReturnSource source = available.stream() ExceptionOrderResponses.ReturnSource source = available.stream()
@@ -284,7 +321,7 @@ public class ExceptionOrderService {
return created; return created;
} }
/** Caller keeps the delivery row locked until return creation commits. */ /** 调用方持有派送行锁,禁止活动退回批次之间重叠冻结。 */
DeliveryOrder lockForReturn(String customerId, String deliveryNo) { DeliveryOrder lockForReturn(String customerId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo); DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在"); if (delivery == null) throw notFound("派送单不存在");
@@ -461,7 +498,7 @@ public class ExceptionOrderService {
private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery, private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery,
String type, String reason, Integer packages, String sku, String type, String reason, Integer packages, String sku,
String direction, String logisticsNo, String sourceType, 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()) { if (packages != null && relation.availablePackages() != null && packages > relation.availablePackages()) {
throw conflict("异常箱数不能大于订单箱数 " + relation.availablePackages()); throw conflict("异常箱数不能大于订单箱数 " + relation.availablePackages());
} }
@@ -482,7 +519,8 @@ public class ExceptionOrderService {
value.setReturnLogisticsNo(trim(logisticsNo)); value.setReturnLogisticsNo(trim(logisticsNo));
if (delivery != null) { if (delivery != null) {
LastlegOrder tail = relation.lastMileOrderId() == null ? null : lastMileOrders.selectById(relation.lastMileOrderId()); 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()))); tail == null ? null : tail.getSubOrderStatus(), tail == null ? null : tail.getAbnormal())));
} }
if (StringUtils.hasText(direction)) { if (StringUtils.hasText(direction)) {
@@ -695,6 +733,7 @@ public class ExceptionOrderService {
private record ReturnContext(String deliveryStatus, Long tailId, String tailStatus, Boolean abnormal) { } private record ReturnContext(String deliveryStatus, Long tailId, String tailStatus, Boolean abnormal) { }
/** 取消只恢复本原单;同批仍有未取消退回时继续保留派送退回中状态。 */
private void restoreCancelledReturn(ExceptionOrder order) { private void restoreCancelledReturn(ExceptionOrder order) {
if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return; if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return;
ReturnContext previous; ReturnContext previous;
@@ -706,7 +745,8 @@ public class ExceptionOrderService {
if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) throw conflict("关联派送状态已变化,不能取消退回"); if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) throw conflict("关联派送状态已变化,不能取消退回");
if (returnItems.findByException(order.getCustomerId(), order.getId()).stream().anyMatch(item -> ReturnReceiptPolicy.received(item) > 0)) if (returnItems.findByException(order.getCustomerId(), order.getId()).stream().anyMatch(item -> ReturnReceiptPolicy.received(item) > 0))
throw conflict("已有实际退回入仓,不能取消退回事实"); 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); delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); deliveryOrders.updateById(delivery);
if (previous.tailId() != null) { if (previous.tailId() != null) {
LastlegOrder tail = lastMileOrders.selectById(previous.tailId()); LastlegOrder tail = lastMileOrders.selectById(previous.tailId());
@@ -34,29 +34,32 @@ public class FleetHandoverPdaQuery {
public record Status(String dispatchNo, String tripNo, String tripStatus, String tripRecordVersion, public record Status(String dispatchNo, String tripNo, String tripStatus, String tripRecordVersion,
FleetTransitEvidence.Transport originalDeparture, List<Delivery> deliveries, Handover latestHandover) { } FleetTransitEvidence.Transport originalDeparture, List<Delivery> deliveries, Handover latestHandover) { }
/** 交接只读范围按原发车仓内实载核对,异地取货量不冒充仓库派送。 */
public Status status(long warehouseId, String dispatchNo) { public Status status(long warehouseId, String dispatchNo) {
var binding = bindings.findByDispatchNo(dispatchNo); var binding = bindings.findByDispatchNo(dispatchNo);
if (binding == null) throw notFound(); if (binding == null) throw notFound();
var trip = trips.findByNo(binding.getTripNo()); 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 (trip == null || !Objects.equals(trip.getWarehouseId(), warehouseId) || !Objects.equals(binding.getTripId(), trip.getId())) throw notFound();
if (!List.of("IN_TRANSIT", "COMPLETED").contains(trip.getStatus())) if (!List.of("IN_TRANSIT", "COMPLETED").contains(trip.getStatus()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "尚未发车,请按原拣货发车流程作业"); throw new ResponseStatusException(HttpStatus.CONFLICT, "尚未发车,请按原拣货发车流程作业");
var evidence = dispatches.transitEvidence(dispatchNo, false); var evidence = dispatches.transitEvidence(dispatchNo, false);
if (!Long.toString(warehouseId).equals(evidence.warehouseId()) 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>(); var deliveryNumbers=new java.util.HashSet<String>();
if(evidence.deliveries()==null || evidence.deliveries().isEmpty() || evidence.deliveries().stream().anyMatch(row -> 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() row==null || row.cargo()==null || row.cargo().customerId()==null || row.cargo().customerId().isBlank()
|| !dispatchNo.equals(row.cargo().dispatchNo()) || row.cargo().deliveryNo()==null || !dispatchNo.equals(row.cargo().dispatchNo()) || row.cargo().deliveryNo()==null
|| !deliveryNumbers.add(row.cargo().deliveryNo()) || row.cargo().packages()<=0) || !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()) var original = history.selectList(new LambdaQueryWrapper<FleetTripHistory>().eq(FleetTripHistory::getTripId, trip.getId())
.eq(FleetTripHistory::getAction, "DEPART")); .eq(FleetTripHistory::getAction, "DEPART"));
if (original.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "缺少唯一原始发车记录"); if (original.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "缺少唯一原始发车记录");
try { 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(); 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 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(), 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(), 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); return new Status(dispatchNo, trip.getTripNo(), trip.getStatus(), trip.getRecordVersion().toString(), originalTransport, rows, handover);
} catch (JsonProcessingException error) { throw new IllegalStateException("交接记录读取失败", error); } } 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) { private static FleetTransitEvidence.Transport transport(FleetHandoverModels.Resource resource) {
return new FleetTransitEvidence.Transport(resource.vehiclePlate(), resource.driverName(), resource.driverPhone()); 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) {} 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 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 ListResult(List<Row> records,int page,int size,int total,Summary summary) {}
/** 散板按商业订单展示,关联托盘与目的地完整保留,箱数只累加真实来源贡献。 */
public record Cargo(String sourceKey,String customerId,String customerName,String orderNo, 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 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, public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,
Operations operations,List<Photo> photos) {} 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.Source;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Search; 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.Record;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.CargoRow;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader; import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.dispatch.port.DispatchContracts; import com.baiyue.lastleg.dispatch.port.DispatchContracts;
@@ -68,14 +69,37 @@ public class OwnedBusinessQueryService {
public Detail detailForWrite(Actor actor,Source source) { public Detail detailForWrite(Actor actor,Source source) {
return detailForWrite(actor,source,OwnedBusinessScope.OWNED); return detailForWrite(actor,source,OwnedBusinessScope.OWNED);
} }
/** 写回执也执行固定归属校验,调用者已核对本域独立写权限。 */ /** 写回执也执行固定归属校验;散板明细复用原排车公开的商业订单归属。 */
public Detail detailForWrite(Actor actor,Source source,OwnedBusinessScope scope) { public Detail detailForWrite(Actor actor,Source source,OwnedBusinessScope scope) {
eligible(source,scope);var ids=List.of(source.getId());var cargo=repository.cargo(ids); 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 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()); 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(), var looseboardOrders=new HashMap<List<String>,String>();
c.getOrderNo(),c.getInboundCode(),c.getPalletNo(),projection.destinationKey(c),String.valueOf(c.getPackageCount()))).toList(), if(original!=null && original.orderDetails()!=null)for(var order:original.orderDetails()) {
projection.photos(repository.record(source.getId()))); 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) { 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) (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 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) 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 FROM ll_fleet_trip t
WHERE t.departed_at &gt;= #{q.from} AND t.departed_at &lt; #{q.to} 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> <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(); var trip=snapshot.trip(); var load=snapshot.confirmedLoad();
if(trip!=null && Set.of("LOCAL_TRUCK","PICKUP").contains(Objects.toString(trip.tripPurpose(),""))) if(trip!=null && Set.of("LOCAL_TRUCK","PICKUP").contains(Objects.toString(trip.tripPurpose(),"")))
return transport(fact,trip); 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()) 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()); throw new IllegalStateException("发车历史缺少装载或实际时间,请核对车次"+fact.tripNo());
var externalByPallet=new HashMap<String,Long>(); var money=new TreeMap<String,BigDecimal>(); var bookingNumbers=new HashSet<String>(); 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) if(!fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || trip.stops()==null || trip.stops().size()<2)
throw new IllegalStateException("运输发车历史缺少行程或实际时间"); throw new IllegalStateException("运输发车历史缺少行程或实际时间");
var facts=fact.transportFacts()==null?null:json.readTree(fact.transportFacts()); 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(); 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("运输箱数不合法"); if(quantity<0)throw new IllegalStateException("运输箱数不合法");
var destinations=new ArrayList<TripOperationsModels.Destination>(); var destinations=new ArrayList<TripOperationsModels.Destination>();
for(int i=1;i<trip.stops().size();i++) { for(int i=1;i<trip.stops().size();i++) {
@@ -15,7 +15,7 @@ public interface InboundPlanQueryMapper {
String SCHEDULED_DISPATCH = "EXISTS (SELECT 1 FROM ll_dispatch_item di" + String SCHEDULED_DISPATCH = "EXISTS (SELECT 1 FROM ll_dispatch_item di" +
" JOIN ll_dispatch_order dispatch ON dispatch.id=di.dispatch_order_id" + " 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" + " 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" + String FROM_AND_FILTER = " FROM ll_inbound_plan_line l" +
" JOIN ll_inbound_plan p ON p.id=l.plan_id" + " JOIN ll_inbound_plan p ON p.id=l.plan_id" +
@@ -44,11 +44,12 @@ public class MerchantLocalLooseboardController {
return ApiResponse.success(service.page(customer(user), query)); return ApiResponse.success(service.page(customer(user), query));
} }
/** 客户详情使用与列表一致的运输进度。 */
@GetMapping("/{orderNo}") @GetMapping("/{orderNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_LIST + "')")
public ApiResponse<LocalLooseboardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<LocalLooseboardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String orderNo) { @PathVariable String orderNo) {
return ApiResponse.success(service.detail(customer(user), orderNo)); return ApiResponse.success(service.merchantDetail(customer(user), orderNo));
} }
@PutMapping("/{orderNo}") @PutMapping("/{orderNo}")
@@ -22,7 +22,8 @@ public final class LocalLooseboardResponses {
String operationType, String fromStatus, String toStatus, String operatorId, String operationType, String fromStatus, String toStatus, String operatorId,
String idempotencyKey, String changeDetail, String remark, LocalDateTime createdAt) { } String idempotencyKey, String changeDetail, String remark, LocalDateTime createdAt) { }
/** 详情独立返回全部有效排车,同车多托合并为一个单号。 */
public record Detail( public record Detail(
Summary summary, String originAddress, String destinationAddress, String remark, 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 @Mapper
public interface LocalLooseboardOrderMapper extends BaseMapper<LocalLooseboardOrder> { 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") @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); 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'") @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 LocalLooseboardOrder lockNo(String customer, String number) { return orders.lockNo(customer, number); }
/** 在客户范围内组合路线、状态与取货日期筛选并返回分页需求。 */ /** 在客户范围内组合路线、状态与取货日期筛选并返回分页需求。 */
public Page<LocalLooseboardOrder> page(LocalLooseboardRequests.Query query) { 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>() LambdaQueryWrapper<LocalLooseboardOrder> wrapper = new LambdaQueryWrapper<LocalLooseboardOrder>()
.eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId())) .eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId()))
.like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo())) .like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo()))
.and(StringUtils.hasText(query.routeKeyword()), value -> value .and(StringUtils.hasText(query.routeKeyword()), value -> value
.like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword())) .like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword()))
.or().like(LocalLooseboardOrder::getDestination, 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) StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null)
.ge(query.requestedFrom() != null, LocalLooseboardOrder::getPickupAt, query.requestedFrom() == null ? null : query.requestedFrom().atStartOfDay()) .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()) .lt(query.requestedTo() != null, LocalLooseboardOrder::getPickupAt, query.requestedTo() == null ? null : query.requestedTo().plusDays(1).atStartOfDay())
.orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId); .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(); } 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); 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.LooseboardReceiving receiving;
@Resource private com.baiyue.lastleg.looseboard.port.LooseboardDispatches dispatches;
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPool; @Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPool;
public static final String PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE"; 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 DISPATCHED = "DISPATCHED";
public static final String IN_TRANSIT = "IN_TRANSIT"; public static final String IN_TRANSIT = "IN_TRANSIT";
public static final String COMPLETED = "COMPLETED"; public static final String COMPLETED = "COMPLETED";
public static final String CANCELLED = "CANCELLED"; 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( private static final Map<String, Set<String>> TRANSITIONS = Map.of(
PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED), PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED),
FUNDING_PENDING, Set.of(CANCELLED),
FUNDING_FAILED, Set.of(CANCELLED),
DISPATCHED, Set.of(IN_TRANSIT, CANCELLED), DISPATCHED, Set.of(IN_TRANSIT, CANCELLED),
IN_TRANSIT, Set.of(COMPLETED)); IN_TRANSIT, Set.of(COMPLETED));
@Resource private LocalLooseboardRepository repository; @Resource private LocalLooseboardRepository repository;
@Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator; @Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Resource private com.baiyue.lastleg.wallet.budget.OrderBudgetService orderBudget;
/** 在同一事务保存客户需求及创建历史,等待需求池匹配后登记真实来货,创建键重复时核对请求哈希。 */ /** 平台代客和客户自助下单共用预算冻结,冻结成功后才进入待审核。 */
@Transactional @Transactional
public LocalLooseboardResponses.Detail create(String customerId, String operatorId, public LocalLooseboardResponses.Detail create(String customerId, String operatorId,
LocalLooseboardRequests.Create request) { 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 key = request.idempotencyKey().trim();
String requestHash = hash(request); String requestHash = hash(request);
LocalLooseboardOrder replay = repository.findKey(customerId, key); 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.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(),
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(),
request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark()); 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.setIdempotencyKey(key);
order.setRequestHash(requestHash); order.setRequestHash(requestHash);
order.setCreatedBy(operatorId); order.setCreatedBy(operatorId);
@@ -96,7 +113,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
if (concurrent != null) return replay(customerId, concurrent, requestHash); if (concurrent != null) return replay(customerId, concurrent, requestHash);
throw exception; throw exception;
} }
history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId, history(order, "CREATE", null, FUNDING_PENDING, operatorId,
key, null, request.remark(), now); key, null, request.remark(), now);
return detail(customerId, order.getOrderNo()); return detail(customerId, order.getOrderNo());
@@ -112,13 +129,28 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
result.getSize(), result.getTotal(), result.getPages()); result.getSize(), result.getTotal(), result.getPages());
} }
/** 客户查询强制使用认证客户范围,沿同一需求筛选合同分页。 */ /** 客户查询使用认证范围,筛选、分页及显示统一按真实运输进度。 */
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<LocalLooseboardResponses.Summary> page(String customerId, public PageResult<LocalLooseboardResponses.Summary> page(String customerId,
LocalLooseboardRequests.MerchantQuery query) { LocalLooseboardRequests.MerchantQuery query) {
return page(new LocalLooseboardRequests.Query(query.page(), query.size(), customerId, var scoped = new LocalLooseboardRequests.Query(query.page(), query.size(), customerId,
query.orderNo(), query.routeKeyword(), query.status(), query.orderNo(), query.routeKeyword(), query.status(), query.requestedFrom(), query.requestedTo());
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 @Transactional
public LocalLooseboardResponses.Detail update(String customerId, String orderNo, String operatorId, public LocalLooseboardResponses.Detail update(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.Update request) { 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); LocalLooseboardOrder order = lock(customerId, orderNo);
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
if (!PENDING_ACCEPTANCE.equals(order.getStatus())) { if (!PENDING_ACCEPTANCE.equals(order.getStatus())) {
@@ -157,6 +197,13 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
@Transactional @Transactional
public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId, public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.ChangeStatus request) { 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); LocalLooseboardOrder order = lock(customerId, orderNo);
String key = request.idempotencyKey().trim(); String key = request.idempotencyKey().trim();
String requestHash = hash(request); String requestHash = hash(request);
@@ -181,6 +228,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
touch(order, now); touch(order, now);
repository.updateOrder(order); repository.updateOrder(order);
syncLastlegOrder(order, next, now); syncLastlegOrder(order, next, now);
if (CANCELLED.equals(next)) orderBudget.releaseAfterReview(customerId, "looseboard", orderNo);
LocalLooseboardOperation operation = new LocalLooseboardOperation(); LocalLooseboardOperation operation = new LocalLooseboardOperation();
operation.setId(IdWorker.getId()); operation.setOrderId(order.getId()); operation.setId(IdWorker.getId()); operation.setOrderId(order.getId());
operation.setCustomerId(customerId); operation.setOperationType("STATUS"); operation.setCustomerId(customerId); operation.setOperationType("STATUS");
@@ -195,6 +243,13 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
@Transactional @Transactional
public LocalLooseboardResponses.Detail review(String customerId, String orderNo, String operatorId, public LocalLooseboardResponses.Detail review(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.Review request) { 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); LocalLooseboardOrder order = lock(customerId, orderNo);
String requestHash = hash(request); String requestHash = hash(request);
var previous = repository.findOperation(customerId, order.getId(), request.idempotencyKey()); 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"); if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
String decision = request.decision().trim().toUpperCase(); 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 ("REJECT".equals(decision)) {
if (!StringUtils.hasText(request.remark())) throw badRequest("驳回须填写原因"); 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 (!"APPROVE".equals(decision)) throw badRequest("审核结果无效");
if (order.getOperatingWarehouseId()==null || order.getDeliveryMode()==null) throw conflict("旧散板订单缺少收货安排,请先编辑补齐"); if (!orderBudget.readOrder(customerId, "looseboard", orderNo).usable()) throw conflict("散板订单资金尚未冻结成功,暂不能审核");
if (order.getOperatingWarehouseId()==null || order.getDeliveryMode()==null) throw conflict("散板订单缺少收货安排,请先编辑补齐");
var created = receiving.approve(order, operatorId); var created = receiving.approve(order, operatorId);
order.setTransferNo(created.transferNo()); order.setLastlegOrderId(created.lastlegOrderId()); order.setStatus("WAITING_INBOUND"); order.setTransferNo(created.transferNo()); order.setLastlegOrderId(created.lastlegOrderId()); order.setStatus("WAITING_INBOUND");
}
touch(order, LocalDateTime.now()); repository.updateOrder(order); 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()); 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); 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); 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; return order;
} }
/** 装配需求详情、真实承运安排与不可变历史。 */ /** 装配需求详情、真实承运安排、全部有效排车与不可变历史。 */
private LocalLooseboardResponses.Detail response(LocalLooseboardOrder 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(), order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(),
repository.histories(order.getCustomerId(), order.getId()).stream() repository.histories(order.getCustomerId(), order.getId()).stream()
.map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(), .map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(),
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(), value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
value.getChangeDetail(), value.getRemark(), 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()); 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) { 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(), 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.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.getDestination(), order.getProductName(), order.getEstimatedPalletCount(), Objects.requireNonNullElse(order.getPackageCount(), 0),
order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(), 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); 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) { 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"); throw badRequest("Unsupported looseboard order status");
} }
if (query.requestedFrom() != null && query.requestedTo() != null if (query.requestedFrom() != null && query.requestedTo() != null
@@ -51,14 +51,15 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
String externalBookingNo, boolean poolSource) { } String externalBookingNo, boolean poolSource) { }
// Manual node snapshots replace container nodes permanently for the order. // 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 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 " + "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) " + "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 " + "THEN 1 ELSE 0 END ELSE " +
"CASE WHEN EXISTS (SELECT 1 FROM ll_container_shipping_gate g " + "CASE WHEN EXISTS (SELECT 1 FROM ll_container_shipping_gate g " +
"WHERE g.container_booking_id=ll_order.source_container_booking_id " + "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, " + @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 " + "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 " + @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 " + "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 " + "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>") "<foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<DispatchStatusRow> findDispatchStatuses(@Param("orderIds") List<Long> orderIds); 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 DispatchStatusRow(Long orderId, String dispatchStatus) { }
record OrderWeightRow(Long orderId, BigDecimal grossWeightKg, BigDecimal netWeightKg) { } record OrderWeightRow(Long orderId, BigDecimal grossWeightKg, BigDecimal netWeightKg) { }
@@ -12,6 +12,10 @@ import lombok.RequiredArgsConstructor;
import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import org.springframework.stereotype.Repository; 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 @Repository
@@ -21,6 +25,21 @@ public class OrderFundingRepository {
private final OverseasProcurementStatusHistoryMapper purchaseHistory; private final OverseasProcurementStatusHistoryMapper purchaseHistory;
private final UsTruckOrderMapper trucks; private final UsTruckOrderMapper trucks;
private final UsTruckOrderStatusHistoryMapper truckHistory; 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); } 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.time.LocalDateTime;
import java.util.Set; import java.util.Set;
/** 两种订单复用资金状态门禁,不反向依赖订单编排服务。 */ /** 采购、卡车和散板复用资金状态门禁,不反向依赖订单编排服务。 */
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class OrderFundingService implements OrderFundingPort { 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); order.setStatus(target); order.setVersion(order.getVersion() + 1); order.setUpdatedAt(now);
repository.save(order); repository.save(order);
repository.history(order, from, target, reason, now); 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 ReturnOrderReferenceResolver returnOrderReferences;
@Resource private com.baiyue.lastleg.transfer.service.TransferQuantityService transferQuantities; @Resource private com.baiyue.lastleg.transfer.service.TransferQuantityService transferQuantities;
@Resource private OrderPreDepartureClient orderPreDepartureClient; @Resource private OrderPreDepartureClient orderPreDepartureClient;
@Resource private com.baiyue.lastleg.repository.PreDepartureOrderRepository preDepartureOrders;
@Value("${baiyue.security.internal-service-token:}") private String internalServiceToken; @Value("${baiyue.security.internal-service-token:}") private String internalServiceToken;
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) { 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) { public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query, String relabelNo) {
if (query.sailingStage() == LastlegOrderRequests.SailingStage.BEFORE_DEPARTURE) { if (query.sailingStage() == LastlegOrderRequests.SailingStage.BEFORE_DEPARTURE) {
Long customer = parseLong(customerId); return preDeparturePage(customerId, query);
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());
} }
if (query.sailingStage() == null && relabelNo == null) return aggregateAllPage(customerId, query); if (query.sailingStage() == null && relabelNo == null) return aggregateAllPage(customerId, query);
Page<LastlegOrder> page = orderMapper.selectPage( Page<LastlegOrder> page = orderMapper.selectPage(
@@ -115,6 +111,27 @@ public class LastlegOrderService {
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); 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) { private List<LastlegOrderSummary> nativeSummaries(List<LastlegOrder> orders) {
Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(orders); Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(orders);
@@ -21,26 +21,26 @@ public class MerchantOrderBudgetController {
@NotBlank @Pattern(regexp="CNY|USD") String currency) { } @NotBlank @Pattern(regexp="CNY|USD") String currency) { }
/** 按业务创建权限发起预估冻结。 */ /** 按业务创建权限发起预估冻结。 */
@PostMapping("/{kind}/{key}") @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) { 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())); return ApiResponse.success(service.prepare(customer(user),kind,key,request.amount(),request.currency()));
} }
/** 只读资金回执沿同一业务权限。 */ /** 只读资金回执沿同一业务权限。 */
@GetMapping("/{kind}/{key}") @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) { public ApiResponse<OrderBudgetService.View> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable String key) {
return ApiResponse.success(service.read(customer(user),kind,key)); return ApiResponse.success(service.read(customer(user),kind,key));
} }
/** 列表及详情可核对当前客户订单资金状态和失败原因。 */ /** 列表及详情可核对当前客户订单资金状态和失败原因。 */
@GetMapping("/{kind}/orders/{orderNo}") @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, public ApiResponse<OrderBudgetService.View> readOrder(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String kind, @PathVariable String orderNo) { @PathVariable String kind, @PathVariable String orderNo) {
return ApiResponse.success(service.readOrder(customer(user), kind, orderNo)); return ApiResponse.success(service.readOrder(customer(user), kind, orderNo));
} }
/** 失败订单按原预算重试;客户身份只取当前会话。 */ /** 失败订单按原预算重试;客户身份只取当前会话。 */
@PostMapping("/{kind}/orders/{orderNo}/retry") @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, public ApiResponse<OrderBudgetService.View> retry(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String kind, @PathVariable String orderNo) { @PathVariable String kind, @PathVariable String orderNo) {
return ApiResponse.success(service.retryOrder(customer(user), kind, 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}) (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='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='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 )) ORDER BY b.id LIMIT 100 FOR UPDATE SKIP LOCKED
""") """)
List<OrderBudget> releasable(@Param("expiredBefore") LocalDateTime expiredBefore, List<OrderBudget> releasable(@Param("expiredBefore") LocalDateTime expiredBefore,
@@ -115,7 +115,15 @@ public class OrderBudgetService implements WalletResultHandler {
return change.get(); 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 @Transactional
public void releaseAfterReview(String customer, String kind, String orderNo) { public void releaseAfterReview(String customer, String kind, String orderNo) {
requireKind(kind); 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)))); };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保存以匹配资金事件。 */ /** 时间按UTC保存以匹配资金事件。 */
private LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); } private LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); }
/** 返回可恢复的业务校验失败。 */ /** 返回可恢复的业务校验失败。 */
@@ -3,6 +3,8 @@ info:
title: 本土散板排车照片履约 title: 本土散板排车照片履约
version: 1.0.0 version: 1.0.0
description: >- description: >-
管理资料按同客户散板原单聚合,每单展示一块并汇总本车各托箱数。itemId为该订单在本车的代表明细,
同单照片一次保存并在同一事务保留各托证据;不同客户或不同订单不能共用节点照片,分车仍分别履约。
仅同客户权威关联的本土散板明细进入照片节点。全车为散板且尚无真实扫码时,审核通过后逐票保存取货照片, 仅同客户权威关联的本土散板明细进入照片节点。全车为散板且尚无真实扫码时,审核通过后逐票保存取货照片,
全部齐全才显示取货完成按钮;显式确认后从LOADING进入DELIVERING。不生成库存、PDA拣货或仓库出库事实。逐票保存送达照片后自动DELIVERED并结案释放车辆司机。 全部齐全才显示取货完成按钮;显式确认后从LOADING进入DELIVERING。不生成库存、PDA拣货或仓库出库事实。逐票保存送达照片后自动DELIVERED并结案释放车辆司机。
混排从AFTER_LOADING或DELIVERING可逐票保存取货及送达照片;异地货同票须先取货再送达,不同票不限制取送顺序。 混排从AFTER_LOADING或DELIVERING可逐票保存取货及送达照片;异地货同票须先取货再送达,不同票不限制取送顺序。
@@ -2,7 +2,7 @@ openapi: 3.0.3
info: info:
title: 派送异常退回入库 title: 派送异常退回入库
version: 1.0.0 version: 1.0.0
description: 排车异常冻结原出库货物,异常单生成退回尾程单后进入PDA退货入库列表。现场展示异常单号、车牌和待收箱数,点击开始作业,不需要回单号或扫码找单。建单不增加库存,分批实收调用原来源RETURN_STOCK_IN或原箱恢复,异常和退回尾程单收齐后完成。 description: 排车异常允许一次选择同派送下多张原订单,逐原单生成异常并冻结对应原出库货物,整批校验与落单原子提交。异常单生成退回尾程单后进入PDA退货入库列表。现场展示异常单号、车牌和待收箱数,点击开始作业,不需要回单号或扫码找单。建单不增加库存,分批实收调用原来源RETURN_STOCK_IN或原箱恢复,异常和退回尾程单收齐后完成。回仓扫描当前仓有效托盘及其实际库位,不要求使用已出库报废的原托;同键实收重放不重复增加库存。
paths: paths:
/api/platform/v1/last-leg/exception-orders/return-inbound-plans: /api/platform/v1/last-leg/exception-orders/return-inbound-plans:
post: post:
@@ -13,7 +13,7 @@
"paths": { "paths": {
"/api/platform/v1/last-leg/fleet/operations": { "/api/platform/v1/last-leg/fleet/operations": {
"get": { "get": {
"description": "platform:fleet:operations:list;一次查询返回分页与全范围汇总", "description": "platform:fleet:operations:list;一次查询返回分页与全范围汇总。纯散板照片直送按LOOSEBOARD_DELIVERING不可变历史的实际发运箱数统计,要求已有实际发车时间且未发生仓内拣货或装车;实托和标准托位保持null,不以申报托数补齐。普通仓内出车仍要求DEPART实载快照。",
"parameters": [ "parameters": [
{ {
"name": "page", "name": "page",
@@ -793,6 +793,7 @@ components:
Cargo: Cargo:
type: "object" type: "object"
x-java-record: "OwnedBusinessModels.Cargo" x-java-record: "OwnedBusinessModels.Cargo"
description: 散板按客户和商业订单一行展示,关联托盘码去重连接,箱数按本单来源累加;其他货物保持原来源行。
required: required:
- "sourceKey" - "sourceKey"
- "customerId" - "customerId"
@@ -815,10 +816,19 @@ components:
nullable: true nullable: true
palletNo: palletNo:
type: "string" type: "string"
nullable: true
description: 关联的完整实托码集合,以顿号连接;未提供保持未知。
destinationKey: destinationKey:
type: "string" type: "string"
description: 首个目的地键;展示使用完整destinationKeys集合。
destinationKeys:
type: array
items:
type: string
description: 本订单全部真实目的地,不能丢失多目的地信息。
packages: packages:
type: "string" type: "string"
nullable: true
Detail: Detail:
type: "object" type: "object"
x-java-record: "OwnedBusinessModels.Detail" x-java-record: "OwnedBusinessModels.Detail"
@@ -2,17 +2,17 @@ openapi: 3.0.3
info: info:
title: 海外客户下单预估资金校验 title: 海外客户下单预估资金校验
version: 1.0.0 version: 1.0.0
description: 客户订单先原子落库并进入资金处理中,资金冻结通过Outbox异步执行。冻结成功后进入待审核,失败进入资金失败并可在原订单上重试;平台仅可审核待审核订单。余额优先、授信补足及不足判断由Finance原子执行;本服务不自行修改钱包。预算预占、订单和冻结指令在同一事务内写入,订单终态释放。 description: 客户订单先原子落库并进入资金处理中,资金冻结通过Outbox异步执行。冻结成功后进入待审核,失败进入资金失败并可在原订单上重试;平台仅可审核待审核订单。余额优先、授信补足及不足判断由Finance原子执行;本服务不自行修改钱包。预算预占、订单和冻结指令在同一事务内写入,审核通过立即申请释放原预算,取消、驳回或终态同样释放;释放失败自动重试。散板冻结成功进入PENDING_ACCEPTANCE,失败进入FUNDING_FAILED。
security: security:
- bearerAuth: [] - bearerAuth: []
paths: paths:
/api/merchant/v1/last-leg/order-budgets/{kind}/{key}: /api/merchant/v1/last-leg/order-budgets/{kind}/{key}:
parameters: 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}} - {name: key, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
post: post:
summary: 兼容接口:发起或读取预算冻结请求 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: requestBody:
required: true required: true
content: content:
@@ -34,16 +34,16 @@ paths:
'403': {description: 身份或业务创建权限不足} '403': {description: 身份或业务创建权限不足}
/api/merchant/v1/last-leg/order-budgets/{kind}/orders/{orderNo}: /api/merchant/v1/last-leg/order-budgets/{kind}/orders/{orderNo}:
parameters: 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}} - {name: orderNo, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
get: get:
summary: 读取订单资金状态 summary: 读取订单资金状态
description: 仅当前客户可读。FUNDING_PENDING期间客户端可短暂轮询;FUNDING_FAILED展示失败原因并允许重试。只有PENDING_REVIEW允许平台审核。 description: 仅当前客户可读。FUNDING_PENDING期间客户端可短暂轮询;FUNDING_FAILED展示失败原因并允许重试。采购和卡车只有PENDING_REVIEW、散板只有PENDING_ACCEPTANCE且资金已冻结才允许平台审核。读取订单资金状态分别要求对应业务list权限,重试要求对应create权限。
responses: responses:
'200': {$ref: '#/components/responses/Result'} '200': {$ref: '#/components/responses/Result'}
/api/merchant/v1/last-leg/order-budgets/{kind}/orders/{orderNo}/retry: /api/merchant/v1/last-leg/order-budgets/{kind}/orders/{orderNo}/retry:
parameters: 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}} - {name: orderNo, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
post: post:
summary: 重试订单资金冻结 summary: 重试订单资金冻结
@@ -14,7 +14,7 @@ paths:
parameters: parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 50, default: 20 } } - { 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: post:
summary: 将已收货散板加入需求排车 summary: 将已收货散板加入需求排车
description: 需capacity-offer:cargo-add或manage以及dispatch:edit、dispatch:list权限;复用正式AddCargo命令,校验版本、幂等、同仓及整托占用,自动刷新目的站和货量并失效审核,不执行出库拣货。 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: /api/platform/v1/last-leg/local-looseboard-orders/{orderNo}/review:
post: post:
summary: 审核散板并创建唯一入仓履约单 summary: 审核散板并创建唯一入仓履约单
description: 权限platform:overseas-business:looseboard:status;通过后送货到仓进入PDA收货,仓库上门取货进入取货排车;审核后即可从散板需求添加申报货物;上门取货沿取货排车上传取货与到仓凭证,实收到仓后原单进入PENDING_DISPATCH并交接原排车货量。客户及幂等键严格校验。 description: 权限platform:overseas-business:looseboard:status;仅冻结成功可通过审核,通过和驳回均释放原预算预占;通过后送货到仓进入PDA收货,仓库上门取货进入取货排车;审核后即可从散板需求添加申报货物;上门取货沿取货排车上传取货与到仓凭证,实收到仓后原单进入PENDING_DISPATCH并交接原排车货量。客户及幂等键严格校验。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
parameters: parameters:
- { name: orderNo, in: path, required: true, schema: { type: string } } - { name: orderNo, in: path, required: true, schema: { type: string } }
@@ -2754,20 +2754,20 @@ paths:
/api/merchant/v1/last-leg/local-looseboard-orders: /api/merchant/v1/last-leg/local-looseboard-orders:
get: get:
summary: 客户分页查询自己的本土散板订单 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: [] }] security: [{ bearerAuth: [] }]
parameters: parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } - { 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: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: orderNo, in: query, schema: { type: string } } - { name: orderNo, in: query, schema: { type: string } }
- { name: routeKeyword, 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: requestedFrom, in: query, schema: { type: string, format: date } }
- { name: requestedTo, 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' } } } } } responses: { '200': { description: 当前客户本土散板订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardPageResponse' } } } } }
post: post:
summary: 客户创建本土散板订单 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: [] }] security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardCreate' } } } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardCreate' } } } }
responses: { '200': { description: 本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 幂等冲突 } } 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 } }] parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }]
get: get:
summary: 客户查询自己的本土散板订单详情 summary: 客户查询自己的本土散板订单详情
description: 权限 merchant:cross-border:pallet-shipping:list;不泄露其他客户订单。 description: 权限 merchant:cross-border:pallet-shipping:list;不泄露其他客户订单;summary.status 使用 MerchantLooseboardStatus,与列表运输状态及筛选一致。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
responses: { '200': { description: 当前客户本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } } responses: { '200': { description: 当前客户本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } }
put: put:
summary: 客户编辑待接单的本土散板订单 summary: 客户编辑待审核的本土散板订单
description: 权限 merchant:cross-border:pallet-shipping:edit;仅 PENDING_ACCEPTANCE 可编辑,version 必填;不能修改状态。 description: 权限 merchant:cross-border:pallet-shipping:edit;仅 PENDING_ACCEPTANCE 可编辑,version 必填;已冻结的预算金额与币种不能通过编辑更改;不能修改状态。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardUpdate' } } } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardUpdate' } } } }
responses: { '200': { description: 更新后的订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 版本或状态冲突 } } responses: { '200': { description: 更新后的订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 版本或状态冲突 } }
@@ -4897,15 +4897,19 @@ components:
destinationContactPhone: { type: string, nullable: true } destinationContactPhone: { type: string, nullable: true }
pickupAt: { type: string, format: date-time, nullable: true, description: 当地日期时间不带时区;按分钟输入 } pickupAt: { type: string, format: date-time, nullable: true, description: 当地日期时间不带时区;按分钟输入 }
deliveryAt: { 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 } version: { type: integer, format: int64 }
createdAt: { type: string, format: date-time } createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time }
fleetArrangement: { $ref: '#/components/schemas/FleetArrangement' } fleetArrangement: { $ref: '#/components/schemas/FleetArrangement' }
LooseboardDetail: LooseboardDetail:
type: object type: object
description: 承运安排位于summary.fleetArrangement,与列表摘要使用同一真实来源;根对象不重复该字段。池来源状态只能由实际履约推进。 description: 承运安排位于summary.fleetArrangement;dispatchNos独立按本客户散板原单的真实来源返回全部有效排车,不依赖车队预订,同车多托去重、分车全部展示、取消排车排除;未排车返回空数组。池来源状态只能由实际履约推进。
required: [summary, histories] required: [summary, histories, dispatchNos]
properties: properties:
summary: { $ref: '#/components/schemas/LooseboardSummary' } summary: { $ref: '#/components/schemas/LooseboardSummary' }
originAddress: { type: string, nullable: true } originAddress: { type: string, nullable: true }
@@ -4913,6 +4917,7 @@ components:
remark: { type: string, nullable: true } remark: { type: string, nullable: true }
createdBy: { type: string, nullable: true } createdBy: { type: string, nullable: true }
histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } } histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } }
dispatchNos: { type: array, uniqueItems: true, items: { type: string }, description: 本客户当前散板单关联的全部有效排车单号,不授予客户整车详情访问权限 }
FleetArrangement: FleetArrangement:
type: object type: object
nullable: true nullable: true
@@ -5291,7 +5296,11 @@ components:
pickupAt: { type: string, format: date-time, description: '取货日期时分,使用无偏移量的本地时间,不转换时区' } pickupAt: { type: string, format: date-time, description: '取货日期时分,使用无偏移量的本地时间,不转换时区' }
deliveryAt: { type: string, format: date-time, description: '送货日期时分,不能早于取货时间,不转换时区' } deliveryAt: { type: string, format: date-time, description: '送货日期时分,不能早于取货时间,不转换时区' }
remark: { type: string, nullable: true, maxLength: 1000 } 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: LooseboardStatusChange:
type: object type: object
required: [version, status, idempotencyKey] required: [version, status, idempotencyKey]
@@ -5302,7 +5311,7 @@ components:
remark: { type: string, nullable: true, maxLength: 1000 } remark: { type: string, nullable: true, maxLength: 1000 }
LooseboardCreate: LooseboardCreate:
type: object 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: properties:
deliveryMode: { type: string, enum: [DELIVERY_TO_WAREHOUSE, WAREHOUSE_PICKUP], description: 送货到仓或仓库上门取货 } deliveryMode: { type: string, enum: [DELIVERY_TO_WAREHOUSE, WAREHOUSE_PICKUP], description: 送货到仓或仓库上门取货 }
operatingWarehouseId: { type: string, description: 实际收货海外仓ID } operatingWarehouseId: { type: string, description: 实际收货海外仓ID }
@@ -5321,7 +5330,7 @@ components:
packageCount: { type: integer, minimum: 0 } packageCount: { type: integer, minimum: 0 }
totalWeightKg: { type: number, minimum: 0 } totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { 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 } currency: { type: string, minLength: 3, maxLength: 3, default: USD }
originContactName: { type: string, minLength: 1, maxLength: 128 } originContactName: { type: string, minLength: 1, maxLength: 128 }
originContactPhone: { type: string, minLength: 1, maxLength: 128 } originContactPhone: { type: string, minLength: 1, maxLength: 128 }
@@ -6771,6 +6780,7 @@ components:
DispatchOrderDetail: DispatchOrderDetail:
type: object type: object
x-java-record: DispatchViews.OrderDetail x-java-record: DispatchViews.OrderDetail
description: 散板按客户和权威商业订单汇总一行,实托去重计数;重量体积仅完整可分摊时汇总,未知不补零。普通来源保持原展示语义。
properties: properties:
customerId: customerId:
type: string type: string
@@ -6808,6 +6818,11 @@ components:
sourceBusinessNo: sourceBusinessNo:
type: string type: string
nullable: true nullable: true
sourceKeys:
type: array
items:
type: string
description: 本展示行包含的全部原来源键,只用于只读关联,不能作为新增或删除货物的命令身份。
required: required:
- customerId - customerId
- customerName - customerName
@@ -6821,6 +6836,7 @@ components:
- sourceKey - sourceKey
- sourceBusinessType - sourceBusinessType
- sourceBusinessNo - sourceBusinessNo
- sourceKeys
DispatchDetail: DispatchDetail:
type: object type: object
x-java-record: DispatchViews.Detail x-java-record: DispatchViews.Detail
@@ -8504,7 +8520,9 @@ components:
pattern: ^[1-9][0-9]*$ pattern: ^[1-9][0-9]*$
returnSelections: returnSelections:
type: array type: array
description: 退回海外仓必填。来源必须属于本车派送,箱数不超过原拣货及未签收余量。 description: 退回海外仓必填,可同时选择同一派送下多张原订单,每个deliveryNo和lastMileOrderId组合只允许一项,各原单分别生成异常。同一派送在行锁内核验批次总退回量不超过未签收余量;所有来源属于本车及对应原单,任一失败整批回滚,原幂等键重放不重复建单或冻结。
minItems: 1
maxItems: 200
items: items:
type: object type: object
required: [deliveryNo, lastMileOrderId, items] required: [deliveryNo, lastMileOrderId, items]
@@ -11,7 +11,7 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
class MerchantTransportPhotoMapperTest { class MerchantTransportPhotoMapperTest {
/** 同编号、混装和撤销安排均不能扩大客户读取范围,实际查询使用正式Mapper。 */ /** 同单分托去重且分车全部保留;同编号、混装和撤销安排不扩大客户读取范围。 */
@Test void queriesOnlyExactCustomerOrderAndActiveDispatches() throws Exception { @Test void queriesOnlyExactCustomerOrderAndActiveDispatches() throws Exception {
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var db=new JdbcTemplate(data); var db=new JdbcTemplate(data);
@@ -24,13 +24,13 @@ class MerchantTransportPhotoMapperTest {
db.update("INSERT INTO ll_local_looseboard_order VALUES('C1','LB1',1),('C2','LB1',2)"); db.update("INSERT INTO ll_local_looseboard_order VALUES('C1','LB1',1),('C2','LB1',2)");
db.update("INSERT INTO ll_inbound_plan VALUES(11,'C1',1),(12,'C2',2)"); db.update("INSERT INTO ll_inbound_plan VALUES(11,'C1',1),(12,'C2',2)");
db.update("INSERT INTO ll_inbound_plan_pallet VALUES(21,11,'C1'),(22,12,'C2')"); db.update("INSERT INTO ll_inbound_plan_pallet VALUES(21,11,'C1'),(22,12,'C2')");
db.update("INSERT INTO ll_dispatch_order VALUES(31,'PC1','ACTIVE','DELIVERY'),(32,'PC2','CANCELLED','DELIVERY'),(33,'PC3','ACTIVE','LOCAL_TRUCK')"); db.update("INSERT INTO ll_dispatch_order VALUES(31,'PC1','ACTIVE','DELIVERY'),(32,'PC2','CANCELLED','DELIVERY'),(33,'PC3','ACTIVE','LOCAL_TRUCK'),(34,'PC4','ACTIVE','DELIVERY')");
db.update("INSERT INTO ll_dispatch_item VALUES(21,'C1',31),(22,'C2',31),(21,'C1',32),(22,'C1',33)"); db.update("INSERT INTO ll_dispatch_item VALUES(21,'C1',31),(21,'C1',31),(22,'C2',31),(21,'C1',32),(22,'C1',33),(21,'C1',34)");
db.update("INSERT INTO ll_dispatch_truck_task VALUES('PC3','C1','TR1','C1:TR1'),('PC2','C1','TR1','C1:TR1'),('PC1','C2','TR1','C2:TR1')"); db.update("INSERT INTO ll_dispatch_truck_task VALUES('PC3','C1','TR1','C1:TR1'),('PC2','C1','TR1','C1:TR1'),('PC1','C2','TR1','C2:TR1')");
var config=new MybatisConfiguration();config.addMapper(MerchantTransportPhotoMapper.class); var config=new MybatisConfiguration();config.addMapper(MerchantTransportPhotoMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet(); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet();
var repository=new MerchantTransportPhotoRepository(new SqlSessionTemplate(factory.getObject()).getMapper(MerchantTransportPhotoMapper.class)); var repository=new MerchantTransportPhotoRepository(new SqlSessionTemplate(factory.getObject()).getMapper(MerchantTransportPhotoMapper.class));
assertThat(repository.looseboardDispatches("C1","LB1")).containsExactly("PC1"); assertThat(repository.looseboardDispatches("C1","LB1")).containsExactly("PC1","PC4");
assertThat(repository.looseboardDispatches("C3","LB1")).isEmpty(); assertThat(repository.looseboardDispatches("C3","LB1")).isEmpty();
assertThat(repository.truckDispatches("C1","TR1")).containsExactly("PC3"); assertThat(repository.truckDispatches("C1","TR1")).containsExactly("PC3");
assertThat(repository.truckDispatches("C2","TR1")).isEmpty(); assertThat(repository.truckDispatches("C2","TR1")).isEmpty();
@@ -9,6 +9,23 @@ import static org.assertj.core.api.Assertions.*;
/** 本车同目的仓跨客户只填一次,先约自动带号,先排可暂缺。 */ /** 本车同目的仓跨客户只填一次,先约自动带号,先排可暂缺。 */
class DispatchDestinationAppointmentsTest { class DispatchDestinationAppointmentsTest {
/** 已预约的编号不能因尚无有效收货确认而丢失,也不能冒充确认时间。 */
@Test void registeredBatchNumberIsVisibleWithoutInventingReceivingConfirmation() {
var batch=new com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.BatchRow(1L,2L,"FBA_APPOINTMENT:1","AP-1","A","SOURCE","FBA:ONT8",22,"ISA-BOOKED","SUCCESS","ONT8",java.time.LocalDateTime.parse("2026-11-05T10:00:00"),1L);
var booked=DispatchDestinationAppointments.booked(batch,null);
assertThat(booked.status()).isEqualTo("REGISTERED");
assertThat(booked.confirmedAt()).isNull();
var rows=resolve(List.of(item("A","ONT8")),maintenance(new DestinationAppointment("FBA:ONT8",null)),List.of(booked));
assertThat(rows).singleElement().satisfies(a->{
assertThat(a.appointmentNo()).isEqualTo("ISA-BOOKED");
assertThat(a.status()).isEqualTo("REGISTERED");assertThat(a.confirmedAt()).isNull();
});
var window=new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:1","AP-1","A","FBA:ONT8","ISA-CURRENT",java.time.Instant.parse("2026-11-06T10:00:00Z"),null,"AG-1",1);
var confirmed=DispatchDestinationAppointments.booked(batch,window);
assertThat(confirmed.appointmentNo()).isEqualTo("ISA-CURRENT");
assertThat(confirmed.confirmedAt()).isEqualTo(window.startAt());
assertThat(confirmed.status()).isEqualTo("SUCCESS");
}
@Test void groupsMultipleCustomersAndPalletsByDestination() { @Test void groupsMultipleCustomersAndPalletsByDestination() {
var rows=resolve(List.of(item("A","ONT8"),item("B","ONT8"),item("A","LGB8")),null,List.of()); var rows=resolve(List.of(item("A","ONT8"),item("B","ONT8"),item("A","LGB8")),null,List.of());
assertThat(rows).extracting(FbaAppointment::destinationWarehouseCode).containsExactly("LGB8","ONT8"); assertThat(rows).extracting(FbaAppointment::destinationWarehouseCode).containsExactly("LGB8","ONT8");
@@ -8,12 +8,64 @@ import com.baiyue.lastleg.dispatch.port.*;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.*; import java.util.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.*;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
class DispatchLooseboardDisplayServiceTest { class DispatchLooseboardDisplayServiceTest {
/** 维护车辆与排车详情共用订单投影,同单两托只展示一行且未知重量不补零。 */
@Test void looseboardOrderCombinesPalletsAndRetainsAllSourceKeys() {
var repository=mock(LooseboardCargoRepository.class);
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB202611050003",11L),new BusinessSource("A","ZY2","LB202611050002",13L)));
var first=item("A","ZY1",11L,"ZY1");var second=item("A","ZY1",12L,"ZY1");var other=item("A","ZY2",13L,"ZY2");
first.setVolumeCbm(new BigDecimal("5"));second.setVolumeCbm(new BigDecimal("5"));other.setVolumeCbm(new BigDecimal("4"));
var result=details(repository,List.of(first,second,other));
assertThat(result).hasSize(2);assertThat(result.get(0).orderNo()).isEqualTo("LB202611050003");
assertThat(result.get(0).palletCount()).isEqualTo(2);assertThat(result.get(0).volumeCbm()).isEqualByComparingTo("10");
assertThat(result.get(0).grossWeightKg()).isNull();assertThat(result.get(0).sourceKeys()).containsExactly("PLAN:11","PLAN:12");
assertThat(result.get(1).palletCount()).isEqualTo(1);assertThat(result.get(1).volumeCbm()).isEqualByComparingTo("4");
assertThat(first.getOrderNo()).isEqualTo("ZY1");assertThat(second.getVolumeCbm()).isEqualByComparingTo("5");
}
/** 同号跨客户不能合并,普通来源不能仅因编号类似散板而变成散板。 */
@Test void sameOrderNumberKeepsCustomerAndOrdinarySourceBoundaries() {
var repository=mock(LooseboardCargoRepository.class);
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",11L),new BusinessSource("B","ZY1","LB1",12L)));
var result=details(repository,List.of(item("A","ZY1",11L,"ZY1"),item("B","ZY1",12L,"ZY1"),item("C","LB1",13L,"REAL"),item("C","LB1",14L,"REAL")));
assertThat(result).hasSize(4);assertThat(result).extracting(DispatchViews.OrderDetail::customerId).containsExactly("A","B","C","C");
assertThat(result.get(2).sourceBusinessType()).isNull();assertThat(result.get(2).containerNo()).isEqualTo("REAL");
}
/** 同一实托关联多个来源只计一托和一次整托测量;缺测量的订单总量仍未知。 */
@Test void sharedPhysicalPalletIsCountedOnceAndPartialMeasurementRemainsUnknown() {
var repository=mock(LooseboardCargoRepository.class);
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",11L)));
var first=item("A","ZY1",11L,"ZY1");var second=item("A","ZY1",12L,"ZY1");
first.setWarehousePalletId(91L);second.setWarehousePalletId(91L);
first.setGrossWeightKg(new BigDecimal("30"));second.setGrossWeightKg(new BigDecimal("30.00"));
first.setVolumeCbm(new BigDecimal("5"));second.setVolumeCbm(new BigDecimal("5.0"));
var combined=details(repository,List.of(first,second)).get(0);
assertThat(combined.palletCount()).isEqualTo(1);assertThat(combined.volumeCbm()).isEqualByComparingTo("5");assertThat(combined.grossWeightKg()).isEqualByComparingTo("30");
second.setWarehousePalletId(92L);second.setVolumeCbm(null);
var partial=details(repository,List.of(first,second)).get(0);
assertThat(partial.palletCount()).isEqualTo(2);assertThat(partial.volumeCbm()).isNull();assertThat(partial.grossWeightKg()).isEqualByComparingTo("60");
second.setWarehousePalletId(91L);second.setGrossWeightKg(new BigDecimal("31"));
assertThat(details(repository,List.of(first,second)).get(0).grossWeightKg()).isNull();
}
/** 混托多订单无法分摊测量时,不给散板复制整托重量、体积或托数。 */
@Test void sharedPalletWithMultipleOrdersDoesNotInventPerOrderMeasurements() {
var repository=mock(LooseboardCargoRepository.class);
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",11L)));
var mixed=item("A","ZY1",11L,"ZY1");mixed.setOrderRefs("ZY1,OTHER");mixed.setGrossWeightKg(new BigDecimal("30"));mixed.setVolumeCbm(new BigDecimal("5"));
var result=details(repository,List.of(mixed));
assertThat(result).hasSize(1);assertThat(result.get(0).palletCount()).isNull();assertThat(result.get(0).grossWeightKg()).isNull();assertThat(result.get(0).volumeCbm()).isNull();
}
/** 调用正式排车关联订单投影,避免为聚合规则复制一份测试实现。 */
private List<DispatchViews.OrderDetail> details(LooseboardCargoRepository repository,List<DispatchItem> cargo) {
var aggregate=new DispatchAggregateService(null,null,null,null,null,null,new ObjectMapper());
ReflectionTestUtils.setField(aggregate,"looseboardDisplay",new DispatchLooseboardDisplayService(repository));
return ReflectionTestUtils.invokeMethod(aggregate,"orderDetails",cargo);
}
/** 历史排车仍按真实关联显示LB原单;不把内部ZY号代填为入仓码或集装箱号。 */ /** 历史排车仍按真实关联显示LB原单;不把内部ZY号代填为入仓码或集装箱号。 */
@Test void historicalDetailsUseBusinessOrderAndNoInventedContainer() { @Test void historicalDetailsUseBusinessOrderAndNoInventedContainer() {
var repository=mock(LooseboardCargoRepository.class); var repository=mock(LooseboardCargoRepository.class);
@@ -64,6 +116,7 @@ class DispatchLooseboardDisplayServiceTest {
/** 构造旧排车保存的履约快照,验证读取时纠正且不改原来源。 */ /** 构造旧排车保存的履约快照,验证读取时纠正且不改原来源。 */
private DispatchItem item(String customer,String number,long pallet,String container) { private DispatchItem item(String customer,String number,long pallet,String container) {
var item=new DispatchItem();item.setCustomerId(customer);item.setOrderNo(number);item.setOrderRefs(number);item.setPlanPalletId(pallet);item.setInboundCode(number); var item=new DispatchItem();item.setCustomerId(customer);item.setOrderNo(number);item.setOrderRefs(number);item.setPlanPalletId(pallet);item.setInboundCode(number);
item.setSourceKey("PLAN:"+pallet);
item.setSourceSnapshotJson("{\"containerNo\":\""+container+"\",\"planNo\":\"plan\"}");return item; item.setSourceSnapshotJson("{\"containerNo\":\""+container+"\",\"planNo\":\"plan\"}");return item;
} }
} }
@@ -19,6 +19,26 @@ import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.*;
class DispatchLooseboardExecutionServiceTest { class DispatchLooseboardExecutionServiceTest {
/** 同一客户散板订单多托只显示一次,保存的照片逐托原子留证并精确计量。 */
@Test void oneOrderSharesPhotosAcrossPalletsWithoutCrossingCustomers() {
var f=new Fixture(false);var cargo=f.repository.cargo(1);
cargo.add(f.item(104,"A","ZY1"));cargo.add(f.item(105,"C","ZY3"));
when(f.sourceRepo.businessSources(anyList())).thenReturn(List.of(
new BusinessSource("A","ZY1","LB1",101L,"WAREHOUSE_PICKUP"),
new BusinessSource("A","ZY1","LB1",104L,"WAREHOUSE_PICKUP"),
new BusinessSource("B","ZY2","LB2",102L,"WAREHOUSE_PICKUP"),
new BusinessSource("C","ZY3","LB1",105L,"WAREHOUSE_PICKUP")));
var initial=f.service.get(f.actor,"PC1");
assertThat(initial.items()).hasSize(3);
assertThat(initial.items().get(0).packages()).isEqualTo(4);
var saved=f.save(101,Phase.PICKUP,1);
assertThat(saved.items().get(0).pickupPhotos()).hasSize(1);
assertThat(saved.items().get(2).pickupPhotos()).isEmpty();
assertThat(f.facts.current(f.row)).extracting(Evidence::itemId).containsExactlyInAnyOrder("101","104");
f.replace(104,Phase.PICKUP,1L,2L);
assertThat(f.service.get(f.actor,"PC1").items().get(0).pickupPhotos()).hasSize(2);
assertThatThrownBy(()->f.save(105,Phase.PICKUP,1)).hasMessageContaining("不能重复使用");
}
/** 装车后仓内货可先送达,异地货独立取送,不要求仓内货重复上传取货凭证。 */ /** 装车后仓内货可先送达,异地货独立取送,不要求仓内货重复上传取货凭证。 */
@Test void warehouseArrivalDoesNotWaitForRemotePickupOrOwnPickupPhoto() { @Test void warehouseArrivalDoesNotWaitForRemotePickupOrOwnPickupPhoto() {
var f=new Fixture(true);f.repository.cargo(1).get(0).setWarehousePalletId(501L); var f=new Fixture(true);f.repository.cargo(1).get(0).setWarehousePalletId(501L);
@@ -0,0 +1,67 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.LoadingMeasurement;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 混排仓内出库允许未知实测,但已知容量超限仍必须拒绝。 */
class DispatchMixedCapacityTest {
/** 仓内九箱已核平时,缺少重量体积不应额外要求异地货补测。 */
@Test void unknownMeasurementsDoNotBlockWarehouseLoading() {
var f=new Fixture(null,null);
assertThatCode(()->f.service.requireRemoteCapacity(f.row,f.measured(null,null,1))).doesNotThrowAnyException();
}
/** 部分未知不能掩盖已知重量已超载。 */
@Test void knownWeightStillBlocksWithUnknownVolume() {
var f=new Fixture("600",null);
assertThatThrownBy(()->f.service.requireRemoteCapacity(f.row,f.measured("401",null,1))).hasMessageContaining("超过本车容量");
}
/** 部分未知不能掩盖已知体积已超载。 */
@Test void knownVolumeStillBlocksWithUnknownWeight() {
var f=new Fixture(null,"30");
assertThatThrownBy(()->f.service.requireRemoteCapacity(f.row,f.measured(null,"21",1))).hasMessageContaining("超过本车容量");
}
/** 无重量体积依据时,明确的托位上限仍然有效。 */
@Test void palletSpacesRemainLimited() {
var f=new Fixture(null,null);
assertThatThrownBy(()->f.service.requireRemoteCapacity(f.row,f.measured(null,null,10))).hasMessageContaining("超过本车容量");
}
/** 已知占用恰好达到容量时允许确认。 */
@Test void exactKnownCapacityIsAllowed() {
var f=new Fixture("600","30");
assertThatCode(()->f.service.requireRemoteCapacity(f.row,f.measured("400","20",9))).doesNotThrowAnyException();
}
/** 隔离容量校验,异地身份及货量均由正式依赖返回。 */
private static class Fixture {
final DispatchAggregateService service=mock(DispatchAggregateService.class,CALLS_REAL_METHODS);
final DispatchAggregate row=new DispatchAggregate();
/** 构造一条异地取货来源与车辆真实容量,不制造未知实测。 */
Fixture(String weight,String volume) {
var repository=mock(DispatchAggregateRepository.class);
var facts=mock(DispatchLooseboardFacts.class);
ReflectionTestUtils.setField(service,"repository",repository);
ReflectionTestUtils.setField(service,"looseboardFacts",facts);
ReflectionTestUtils.setField(service,"json",new ObjectMapper());
row.setId(1L);
row.setResourcesJson("{\"vehicle\":{\"capacity\":{\"palletSpaces\":10,\"maxLoadKg\":1000,\"maxVolumeCbm\":50}}}");
var remote=new DispatchItem();remote.setId(2L);
remote.setGrossWeightKg(weight==null?null:new BigDecimal(weight));
remote.setVolumeCbm(volume==null?null:new BigDecimal(volume));
when(facts.remoteItemIds(1L)).thenReturn(Set.of(2L));
when(repository.cargo(1L)).thenReturn(List.of(remote));
}
/** 保留仓内原始可空测量值供校验使用。 */
List<LoadingMeasurement> measured(String weight,String volume,int spaces) {
return List.of(new LoadingMeasurement(3L,spaces,weight==null?null:new BigDecimal(weight),volume==null?null:new BigDecimal(volume),1));
}
}
}
@@ -62,6 +62,13 @@ class DispatchPdaReadPersistenceTest {
assertThat(task.items()).allSatisfy(item->assertThat(item.positions()).hasSize(1)); assertThat(task.items()).allSatisfy(item->assertThat(item.positions()).hasSize(1));
assertThatThrownBy(()->f.tx.execute(s->queries.task(new DispatchContracts.Actor("foreign",ActorMode.PDA,"99"),created.dispatchNo()))) assertThatThrownBy(()->f.tx.execute(s->queries.task(new DispatchContracts.Actor("foreign",ActorMode.PDA,"99"),created.dispatchNo())))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("当前绑定仓无此排车作业"); .isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("当前绑定仓无此排车作业");
for (String type : List.of("LOCAL_TRUCK", "PICKUP")) {
f.db.update("UPDATE ll_dispatch_order SET task_type=?", type);
assertThat(f.tx.<List<DispatchViews.PdaTask>>execute(s->queries.list(actor,null))).isEmpty();
assertThatThrownBy(()->f.tx.execute(s->queries.task(actor,created.dispatchNo())))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class);
}
f.db.update("UPDATE ll_dispatch_order SET task_type='DELIVERY'");
f.db.update("DELETE FROM ll_dispatch_pda_pick_order WHERE customer_id='202'"); f.db.update("DELETE FROM ll_dispatch_pda_pick_order WHERE customer_id='202'");
assertThat(f.tx.execute(s->queries.task(actor,created.dispatchNo())).pickingReconciled()).isFalse(); assertThat(f.tx.execute(s->queries.task(actor,created.dispatchNo())).pickingReconciled()).isFalse();
} }
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import com.baiyue.lastleg.dispatch.repository.*;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
/** 散板加货按完整订单分页,来源跨页和客户隔离不能丢失。 */
class LooseboardCargoServiceTest {
/** 同单51托跨来源页仍为一行,另一客户同号订单独立分页。 */
@Test void orderPaginationIncludesEveryPalletAndKeepsCustomersSeparate() throws Exception {
var cargo=mock(LooseboardCargoRepository.class);var repository=mock(DispatchAggregateRepository.class);
var workflow=mock(DispatchAggregateService.class);var json=new ObjectMapper();
var service=new LooseboardCargoService(cargo,repository,workflow,new DispatchCommandHasher(json));
var root=new DispatchAggregate();root.setTaskType("DELIVERY");root.setStatus("ACTIVE");root.setPublishLooseboardDemand(true);root.setOverseasWarehouseId("9");
when(repository.find("PC1")).thenReturn(root);when(cargo.readyPlans("9")).thenReturn(List.of(1L));
var sources=new ArrayList<Candidate>();for(int i=1;i<=51;i++)sources.add(candidate(json,i,"A",i==51));
sources.add(candidate(json,52,"B",false));
when(workflow.candidates(any(),any())).thenAnswer(call->{
CandidateQuery query=call.getArgument(1);int offset=(query.page()-1)*50;
return new CandidateList(sources.subList(offset,Math.min(offset+50,sources.size())),Instant.now(),false,52,query.page(),50,2);
});
var actor=new DispatchContracts.Actor("operator",DispatchTypes.ActorMode.PLATFORM,null);
var first=service.candidates(actor,"PC1",1,1);
assertThat(first.total()).isEqualTo(2);assertThat(first.pages()).isEqualTo(2);
assertThat(first.candidates()).singleElement().satisfies(order->{
assertThat(order.availablePackages()).isEqualTo(102);
assertThat(order.selectionGroup().sources()).hasSize(51);
assertThat(order.selectionGroup().pallets()).hasSize(51);
assertThat(order.blockReasons()).containsExactly("最后一托被占用");
});
assertThat(service.candidates(actor,"PC1",2,1).candidates()).singleElement().satisfies(order->{
assertThat(order.customerId()).isEqualTo("B");assertThat(order.availablePackages()).isEqualTo(2);
});
}
/** 构造带权威选择版本、物理组成及阻断事实的来源。 */
private static Candidate candidate(ObjectMapper json,int id,String customer,boolean blocked) throws Exception {
return json.readValue("""
{"selection":{"sourceType":"INBOUND_PLAN","planPalletId":%d,"sourceVersion":"v%d"},"sourceKey":"K%d","sourceVersion":"v%d",
"customerId":"%s","orderNo":"LB1","warehouseId":"9","availablePackages":2,"appointmentBatches":[],"blockReasons":%s,
"selectionGroup":{"groupKey":"G%d","groupVersion":"g%d","complete":true,"blockReasons":[],
"sources":[{"sourceKey":"K%d","customerId":"%s","packages":2,"selection":{"sourceType":"INBOUND_PLAN","planPalletId":%d,"sourceVersion":"v%d"}}],
"pallets":[{"palletNo":"P%d","sourceKeys":["K%d"],"packageCount":2}]}}
""".formatted(id,id,id,id,customer,blocked?"[\"最后一托被占用\"]":"[]",id,id,id,customer,id,id,id,id),Candidate.class);
}
}
@@ -207,7 +207,7 @@ class ExceptionOrderLifecycleTest {
var order = order("PENDING", 1); var order = order("PENDING", 1);
order.setExceptionType("RETURN"); order.setDeliveryNo("DL-1"); order.setExceptionType("RETURN"); order.setDeliveryNo("DL-1");
order.setReturnContextJson("{\"deliveryStatus\":\"DELIVERED\",\"tailId\":null,\"tailStatus\":null,\"abnormal\":null}"); order.setReturnContextJson("{\"deliveryStatus\":\"DELIVERED\",\"tailId\":null,\"tailStatus\":null,\"abnormal\":null}");
var delivery = new DeliveryOrder(); delivery.setStatus("RETURN_PENDING"); delivery.setVersion(3L); var delivery = new DeliveryOrder(); delivery.setId(71L); delivery.setStatus("RETURN_PENDING"); delivery.setVersion(3L);
var signedAt = LocalDateTime.of(2026, 9, 1, 12, 0); delivery.setSignedAt(signedAt); var signedAt = LocalDateTime.of(2026, 9, 1, 12, 0); delivery.setSignedAt(signedAt);
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order); when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
@@ -36,7 +36,7 @@ class ReturnCartonReturnPersistenceTest {
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
db=new JdbcTemplate(data);tx=new TransactionTemplate(new DataSourceTransactionManager(data)); db=new JdbcTemplate(data);tx=new TransactionTemplate(new DataSourceTransactionManager(data));
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true); var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
var mappers=List.of(ExceptionOrderMapper.class,ExceptionOrderCommandMapper.class,com.baiyue.lastleg.mapper.LastlegOrderMapper.class,com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class,ExceptionReturnItemMapper.class,ReturnCartonStockReceiptMapper.class, var mappers=List.of(ExceptionWorkflowMapper.class,ExceptionOrderMapper.class,ExceptionOrderCommandMapper.class,com.baiyue.lastleg.mapper.LastlegOrderMapper.class,com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class,ExceptionReturnItemMapper.class,ReturnCartonStockReceiptMapper.class,
ReturnInventoryCartonMapper.class,ReturnInventoryCartonItemMapper.class,ReturnInventoryBalanceMapper.class,ReturnInventoryMovementMapper.class, ReturnInventoryCartonMapper.class,ReturnInventoryCartonItemMapper.class,ReturnInventoryBalanceMapper.class,ReturnInventoryMovementMapper.class,
DeliveryOrderMapper.class,OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaPickMapper.class,OutboundPdaTaskMapper.class,DispatchAggregateMapper.class,DispatchReadMapper.class,DispatchItemMapper.class,DispatchFeeCargoMapper.class, DeliveryOrderMapper.class,OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaPickMapper.class,OutboundPdaTaskMapper.class,DispatchAggregateMapper.class,DispatchReadMapper.class,DispatchItemMapper.class,DispatchFeeCargoMapper.class,
DispatchPdaTaskMapper.class,DispatchPdaPickMapper.class,DispatchPdaPickOrderMapper.class,DeliveryOperationMapper.class); DispatchPdaTaskMapper.class,DispatchPdaPickMapper.class,DispatchPdaPickOrderMapper.class,DeliveryOperationMapper.class);
@@ -165,6 +165,75 @@ class ReturnCartonReturnPersistenceTest {
assertThatThrownBy(()->tx.execute(t->business.markReturn("C","OP","操作员","DL-1",foreign))).hasMessageContaining("主单不属于该派送单"); assertThatThrownBy(()->tx.execute(t->business.markReturn("C","OP","操作员","DL-1",foreign))).hasMessageContaining("主单不属于该派送单");
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero(); assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero();
} }
/** 同派送两张原单可一起退回,部分签收数量保留,同键重放不增异常。 */
@Test void multipleMastersOfOneDeliveryFreezeTogetherAndReplayWithoutDuplicateExceptions() {
var requests=prepareTwoMasterReturns();var business=business();
var created=tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests));
assertThat(created).hasSize(2);
assertThat(db.queryForList("SELECT last_mile_order_id FROM ll_exception_order ORDER BY last_mile_order_id",Long.class)).containsExactly(100L,101L);
assertThat(db.queryForObject("SELECT SUM(approved_return_packages) FROM ll_exception_return_item",Long.class)).isEqualTo(2);
assertThat(db.queryForObject("SELECT signed_packages FROM ll_delivery_order WHERE id=3",Long.class)).isEqualTo(2);
var replay=tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests));
assertThat(replay).allSatisfy(row->assertThat(row.replayed()).isTrue());
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isEqualTo(2);
}
/** 从排车适配器传入两张原单,真实服务分别建异常;各来源仍保持独立。 */
@Test void dispatchAdapterCreatesBothReturnsThroughRealService() {
var requests=prepareTwoMasterReturns();var business=business();
var repository=mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class);
when(repository.deliveries(10L)).thenReturn(List.of(new com.baiyue.lastleg.exceptionorder.mapper.ExceptionWorkflowMapper.Delivery("C","DL-1")));
var adapter=new DispatchExceptionAdapter(repository,business);
var selections=requests.stream().map(r->new com.baiyue.lastleg.dispatch.port.DispatchExceptionPort.Selection("DL-1",r.lastMileOrderId(),
r.items().stream().map(i->new com.baiyue.lastleg.dispatch.port.DispatchExceptionPort.Item(i.outboundPalletItemId(),i.inventoryPositionId(),i.returnCartonId(),i.packageCount())).toList())).toList();
var command=new com.baiyue.lastleg.dispatch.port.DispatchCommands.MarkException(0L,"adapter-multiple",com.baiyue.lastleg.dispatch.port.DispatchTypes.ExceptionType.PARTIAL_RECEIPT,
com.baiyue.lastleg.dispatch.port.DispatchTypes.ReturnDirection.OVERSEAS_WAREHOUSE,"多单部分签收",List.of(),selections);
tx.executeWithoutResult(t->adapter.register(10L,"OP",command));
assertThat(db.queryForList("SELECT last_mile_order_id FROM ll_exception_order ORDER BY last_mile_order_id",Long.class)).containsExactly(100L,101L);
assertThat(db.queryForObject("SELECT SUM(approved_return_packages) FROM ll_exception_return_item",Long.class)).isEqualTo(2);
}
/** 多单退回可按任意顺序取消;最后一单取消才恢复批次前状态,签收历史保持不变。 */
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans={false,true})
void cancellingBothMastersRestoresOriginalStateInEitherOrder(boolean reversed) {
var requests=prepareTwoMasterReturns();var business=business();
var created=new ArrayList<>(tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests)));
if(reversed)Collections.reverse(created);
for(int index=0;index<created.size();index++) {
var row=created.get(index);
tx.executeWithoutResult(t->business.cancel("C","OP",row.exceptionNo(),new ExceptionOrderRequests.Cancel(0,"cancel-"+row.id(),"取消本单退回")));
assertThat(db.queryForObject("SELECT status FROM ll_delivery_order WHERE id=3",String.class)).isEqualTo(index==0?"RETURN_PENDING":"DELIVERING");
}
assertThat(db.queryForObject("SELECT signed_packages FROM ll_delivery_order WHERE id=3",Long.class)).isEqualTo(2);
assertThat(db.queryForList("SELECT abnormal FROM ll_order",String.class)).doesNotContain("true");
}
/** 第二单借用第一单原箱时,第一单异常和冻结数量也必须回滚。 */
@Test void invalidSecondMasterRollsBackTheWholeMultiOrderReturn() {
var requests=prepareTwoMasterReturns();var business=business();
var wrong=new ExceptionOrderRequests.MarkReturn(101L,"原因","OVERSEAS_WAREHOUSE",null,requests.get(0).items(),"batch-wrong");
assertThatThrownBy(()->tx.execute(t->business.markReturns("C","OP","操作员","DL-1",List.of(requests.get(0),wrong))))
.hasMessageContaining("不属于该派送单和尾程主单");
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero();
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_return_item",Long.class)).isZero();
assertThat(db.queryForObject("SELECT status FROM ll_delivery_order WHERE id=3",String.class)).isEqualTo("DELIVERING");
}
/** 单笔数量均合法也不能让合计退回超过部分签收后的未签数量。 */
@Test void multiOrderTotalCannotIncludeAlreadySignedPackages() {
var requests=prepareTwoMasterReturns();var business=business();
db.update("UPDATE ll_delivery_order SET signed_packages=3 WHERE id=3");
assertThatThrownBy(()->tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests))).hasMessageContaining("退回总箱数");
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero();
}
/** 两张原单共享派送单,但各自只拥有一只已实际出库的原箱。 */
private List<ExceptionOrderRequests.MarkReturn> prepareTwoMasterReturns() {
prepareMarkReturn();
db.update("UPDATE ll_delivery_order SET status='DELIVERING',package_count=4,signed_packages=2 WHERE id=3");
db.update("INSERT INTO ll_order(id,customer_id,order_no,delivery_order_no,outbound_order_no,overseas_warehouse_id,package_count) VALUES(101,'C','LM-2','DL-1','OUT-1','92',1)");
db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,return_carton_id,packages,product_quantity) VALUES(104,'C',102,4,102,1,1)");
db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,packages) VALUES(106,'C',104,101,1)");
db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id,carton_mark,status,total_quantity) VALUES(102,'C','92','BOX-2','OUTBOUNDED',1)");
return List.of(new ExceptionOrderRequests.MarkReturn(100L,"多单部分签收","OVERSEAS_WAREHOUSE",null,List.of(new ExceptionOrderRequests.ReturnItem(5L,null,1,101L)),"batch-master-1"),
new ExceptionOrderRequests.MarkReturn(101L,"多单部分签收","OVERSEAS_WAREHOUSE",null,List.of(new ExceptionOrderRequests.ReturnItem(5L,null,1,102L)),"batch-master-2"));
}
/** 将真实已出库换标主单置为拒签,不添加普通入仓关系。 */ /** 将真实已出库换标主单置为拒签,不添加普通入仓关系。 */
private void prepareMarkReturn() { private void prepareMarkReturn() {
db.update("DELETE FROM ll_exception_return_item");db.update("DELETE FROM ll_exception_order"); db.update("DELETE FROM ll_exception_return_item");db.update("DELETE FROM ll_exception_order");
@@ -174,7 +243,8 @@ class ReturnCartonReturnPersistenceTest {
/** 使用真实Mapper组装异常业务服务,资源和文件网关维持隔离。 */ /** 使用真实Mapper组装异常业务服务,资源和文件网关维持隔离。 */
private ExceptionOrderService business() { private ExceptionOrderService business() {
var business=new ExceptionOrderService(); var business=new ExceptionOrderService();
org.springframework.test.util.ReflectionTestUtils.setField(business,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class)); org.springframework.test.util.ReflectionTestUtils.setField(business,"workflow",new com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository(
map(ExceptionWorkflowMapper.class),map(ExceptionOrderMapper.class),map(com.baiyue.lastleg.mapper.LastlegOrderMapper.class)));
org.springframework.test.util.ReflectionTestUtils.setField(business,"exceptionOrders",map(ExceptionOrderMapper.class)); org.springframework.test.util.ReflectionTestUtils.setField(business,"exceptionOrders",map(ExceptionOrderMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"returnItems",map(ExceptionReturnItemMapper.class)); org.springframework.test.util.ReflectionTestUtils.setField(business,"returnItems",map(ExceptionReturnItemMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"commands",map(ExceptionOrderCommandMapper.class)); org.springframework.test.util.ReflectionTestUtils.setField(business,"commands",map(ExceptionOrderCommandMapper.class));
@@ -13,6 +13,24 @@ import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
class FleetHandoverPdaQueryTest { class FleetHandoverPdaQueryTest {
/** 混排整车109箱,仓内真实发运9箱时按原实载客户及目的地读取交接。 */
@Test void mixedDispatchUsesOriginalWarehouseLoadBeforeAndAfterRemotePickup() {
var binding=bindings.findByDispatchNo("PC-A");binding.setPackageCount(109);
var departed=new FleetTripHistory();departed.setSnapshotJson("""
{"trip":{"tripNo":"TR-ONE","warehouseId":"92","vehiclePlate":"ORIGINAL"},
"dispatches":[{"dispatchNo":"PC-A","packageCount":109}],
"confirmedLoad":{"cargo":[{"dispatchNo":"PC-A","customerId":"CUSTOMER-A","warehousePalletId":"501","destinationKey":"FBA:ONE","packages":9}]}}
""");
when(history.selectList(any())).thenReturn(List.of(departed));
var delivery=new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A","PC-A","DL-9","FBA:ONE",9,Instant.now(),List.of()),"DELIVERED",1,new FleetTransitEvidence.Transport("ORIGINAL","原司机",null));
for(int shipped:List.of(9,109)){
when(dispatches.transitEvidence("PC-A",false)).thenReturn(new FleetTransitEvidence("PC-A","92",shipped,List.of(delivery)));
assertEquals(9,service.status(92,"PC-A").deliveries().get(0).packages());
}
var wrongCustomer=new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("OTHER","PC-A","DL-9","FBA:ONE",9,null,List.of()),"DELIVERING",1,delivery.transport());
when(dispatches.transitEvidence("PC-A",false)).thenReturn(new FleetTransitEvidence("PC-A","92",109,List.of(wrongCustomer)));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.status(92,"PC-A"));
}
final FleetTripDispatchMapper bindings = mock(FleetTripDispatchMapper.class); final FleetTripDispatchMapper bindings = mock(FleetTripDispatchMapper.class);
final FleetTripMapper trips = mock(FleetTripMapper.class); final FleetTripMapper trips = mock(FleetTripMapper.class);
final FleetTripHistoryMapper history = mock(FleetTripHistoryMapper.class); final FleetTripHistoryMapper history = mock(FleetTripHistoryMapper.class);
@@ -18,6 +18,28 @@ import static org.mockito.Mockito.*;
/** 同筛选完整统计、旧单不回填和权限隔离的直接回归。 */ /** 同筛选完整统计、旧单不回填和权限隔离的直接回归。 */
class OwnedBusinessQueryTest { class OwnedBusinessQueryTest {
/** 自有、外配共享散板订单明细,跨客户同号不合并,目的地和托盘都不丢失。 */
@Test void detailsGroupLooseboardSourcesUsingDispatchOrderAuthorityInBothScopes() {
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();var dispatch=mock(DispatchDetailReader.class);
var original=mock(com.baiyue.lastleg.dispatch.port.DispatchViews.Detail.class);
var first=OwnedBusinessFixtures.cargo().get(0);var second=OwnedBusinessFixtures.cargo().get(1);var other=OwnedBusinessFixtures.cargo().get(0);
second.setCustomerId(first.getCustomerId());second.setWarehousePalletId(32L);second.setPalletNo("PAL-32");second.setDestinationWarehouseCode("LAX9");second.setPackageCount(8);
other.setCustomerId("102");other.setOrderNo("LB-SAME-NUMBER");
when(repo.cargo(any())).thenReturn(List.of(first,second,other));when(repo.batches(any())).thenReturn(List.of());
when(dispatch.detail(any(),any())).thenReturn(original);
when(original.orderDetails()).thenReturn(List.of(new com.baiyue.lastleg.dispatch.port.DispatchViews.OrderDetail("101","客户A","LB1",null,null,2,null,null,null,first.getSourceKey(),"LOCAL_LOOSEBOARD","LB1",List.of(first.getSourceKey(),second.getSourceKey())),
new com.baiyue.lastleg.dispatch.port.DispatchViews.OrderDetail("102","客户B","LB1",null,null,1,null,null,null,other.getSourceKey(),"LOCAL_LOOSEBOARD","LB1",List.of(other.getSourceKey()))));
var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,dispatch);
for(var scope:List.of(com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope.OWNED,com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope.OUTSOURCED)) {
var source=OwnedBusinessFixtures.source();source.setPooledCargo(scope.isPooledCargo());
var result=service.detailForWrite(OwnedBusinessFixtures.actor(),source,scope);
assertEquals(2,result.cargo().size());var order=result.cargo().get(0);
assertEquals("LB1",order.orderNo());assertNull(order.inboundCode());assertEquals("9",order.packages());
assertEquals("PAL-31、PAL-32",order.palletNo());assertEquals(List.of("FBA:ONT8","FBA:LAX9"),order.destinationKeys());
assertEquals("102",result.cargo().get(1).customerId());assertEquals("LB1",result.cargo().get(1).orderNo());
}
assertEquals("ORDER-IN-A",first.getOrderNo());verify(repo,never()).update(any(),anyLong());
}
/** 当前页只有一车时统计仍来自全量,日期边界转换为UTC且共用过滤。 */ /** 当前页只有一车时统计仍来自全量,日期边界转换为UTC且共用过滤。 */
@Test void summaryDoesNotUseCurrentPageLengthAndDateUsesPlannedDeparture() { @Test void summaryDoesNotUseCurrentPageLengthAndDateUsesPlannedDeparture() {
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec(); var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();
@@ -11,6 +11,33 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
class TripOperationsServiceTest { class TripOperationsServiceTest {
/** 照片直送没有仓内装载;与普通出库一起查询时仍保留真实车次及箱数。 */
@Test void photoLooseboardDepartureDoesNotBreakTheWholeReport() {
var repo=mock(TripOperationsRepository.class);
var service=new TripOperationsService(repo,new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class));
String snapshot="""
{"trip":{"tripNo":"TR-PHOTO","tripPurpose":"DELIVERY","departedAt":"2026-10-01T00:00:00Z",
"vehiclePlate":"PHOTO-PLATE","stops":[{"name":"取货地"},{"name":"目的仓","destinationKey":"FBA:ONT8"}]}}
""";
var query=new TripOperationsModels.Query(1,20,Instant.parse("2026-10-01T00:00:00Z"),Instant.parse("2026-10-03T00:00:00Z"),null,null,null,null);
var photo=new TripOperationsRepository.Departure("TR-PHOTO","IN_TRANSIT",null,snapshot,null,"{\"taskType\":\"PHOTO_LOOSEBOARD\",\"packages\":6}");
when(repo.departures(query)).thenReturn(List.of(photo,new TripOperationsRepository.Departure("TR-A","COMPLETED",null,DEPARTURE,null)));
var result=service.report(query,false);
assertThat(result.page().records()).hasSize(2);
assertThat(result.summary().trips()).isEqualTo(2);
assertThat(result.summary().ownPackages()).isEqualTo(17);
assertThat(result.summary().physicalPallets()).isEqualTo(1);
var row=result.page().records().get(0);
assertThat(row.physicalPallets()).isNull();assertThat(row.palletSpaces()).isNull();
assertThat(row.destinations().get(0).label()).isEqualTo("目的仓");
// 普通车次仍不能因缺少装载而被静默当成照片直送。
assertThatThrownBy(()->service.project(new TripOperationsRepository.Departure("TR-PHOTO","IN_TRANSIT",null,snapshot,null)))
.hasMessageContaining("缺少装载");
for(String quantity:List.of("null","0","-1","1.5","\"6\"")) {
assertThatThrownBy(()->service.project(new TripOperationsRepository.Departure("TR-PHOTO","IN_TRANSIT",null,snapshot,null,
"{\"taskType\":\"PHOTO_LOOSEBOARD\",\"packages\":"+quantity+"}"))).hasMessageContaining("实际箱数");
}
}
static final String DEPARTURE=""" static final String DEPARTURE="""
{"trip":{"tripNo":"TR-A","departedAt":"2026-10-01T00:00:00Z","vehiclePlate":"OLD-PLATE","driverName":"发车司机","warehouseName":"起运仓"}, {"trip":{"tripNo":"TR-A","departedAt":"2026-10-01T00:00:00Z","vehiclePlate":"OLD-PLATE","driverName":"发车司机","warehouseName":"起运仓"},
"confirmedLoad":{"cargo":[ "confirmedLoad":{"cargo":[
@@ -32,7 +32,7 @@ class InboundPlanDispatchStatusPersistenceTest {
db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))"); db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))");
db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))"); db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))");
db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,order_id BIGINT)"); db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,order_id BIGINT)");
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,status VARCHAR(30),stage VARCHAR(30))"); db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,status VARCHAR(30),stage VARCHAR(30),task_type VARCHAR(30) DEFAULT 'DELIVERY')");
db.update("INSERT INTO ll_inbound_plan VALUES(1,'LIP-1','A',90,TRUE,FALSE,FALSE,'RECEIVED',CURRENT_TIMESTAMP,0)"); db.update("INSERT INTO ll_inbound_plan VALUES(1,'LIP-1','A',90,TRUE,FALSE,FALSE,'RECEIVED',CURRENT_TIMESTAMP,0)");
db.update("INSERT INTO ll_inbound_plan_line VALUES(2,1,3,'IN-1','91','ABE4','ABE4','ABE4',1,1,1)"); db.update("INSERT INTO ll_inbound_plan_line VALUES(2,1,3,'IN-1','91','ABE4','ABE4','ABE4',1,1,1)");
db.update("INSERT INTO ll_order VALUES(3,'A','LX-1','FIRST_LEG','LA','洛杉矶仓','客户A')"); db.update("INSERT INTO ll_order VALUES(3,'A','LX-1','FIRST_LEG','LA','洛杉矶仓','客户A')");
@@ -64,6 +64,15 @@ class InboundPlanDispatchStatusPersistenceTest {
assertStatus("UNSCHEDULED"); assertStatus("UNSCHEDULED");
} }
@Test
void pickupTripDoesNotCountAsOutboundScheduling() {
dispatch(10, "ACTIVE", "DELIVERED", "A");
db.update("UPDATE ll_dispatch_order SET task_type='PICKUP'");
assertStatus("UNSCHEDULED");
dispatch(11, "ACTIVE", "BEFORE_LOADING", "A");
assertStatus("SCHEDULED");
}
@Test @Test
void departedAndCompletedDispatchesRemainScheduled() { void departedAndCompletedDispatchesRemainScheduled() {
dispatch(10, "ACTIVE", "DELIVERING", "A"); dispatch(10, "ACTIVE", "DELIVERING", "A");
@@ -110,7 +119,7 @@ class InboundPlanDispatchStatusPersistenceTest {
} }
private void dispatch(long id, String status, String stage, String itemOwner) { private void dispatch(long id, String status, String stage, String itemOwner) {
db.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, status, stage); db.update("INSERT INTO ll_dispatch_order(id,status,stage) VALUES(?,?,?)", id, status, stage);
db.update("INSERT INTO ll_dispatch_item VALUES(?,?,?,3)", id, itemOwner, id); db.update("INSERT INTO ll_dispatch_item VALUES(?,?,?,3)", id, itemOwner, id);
} }
@@ -0,0 +1,220 @@
package com.baiyue.lastleg.looseboard;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
import com.baiyue.lastleg.looseboard.port.LooseboardDispatches;
import com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository;
import com.baiyue.lastleg.looseboard.service.LocalLooseboardService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class LocalLooseboardMerchantStatusTest {
private JdbcTemplate jdbc;
private LocalLooseboardRepository repository;
private LocalLooseboardService service;
/** 在隔离库中执行真实 Mapper、分页计数和客户服务,不依赖运行环境数据。 */
@BeforeEach
void setUp() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(ds);
String baseline;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
int start = baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (");
String ddl = baseline.substring(start, baseline.indexOf(';', start))
.replaceAll("(?m)^\\s*(?:UNIQUE KEY|KEY) [^\\n]*\\n?", "");
ddl = ddl.substring(0, ddl.indexOf(") ENGINE=")) + ")";
jdbc.execute(ddl.replaceAll(",\\s*\\)$", ")"));
jdbc.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY, source_order_id BIGINT, customer_id VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY, plan_id BIGINT, customer_id VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_dispatch_item(plan_pallet_id BIGINT, dispatch_order_id BIGINT, customer_id VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY, status VARCHAR(32), stage VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT PRIMARY KEY, transfer_no VARCHAR(64), customer_id VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_fleet_pickup_task(transfer_order_id BIGINT, trip_no VARCHAR(64), customer_id VARCHAR(64), status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_fleet_trip(trip_no VARCHAR(64) PRIMARY KEY, status VARCHAR(32))");
var config = new MybatisConfiguration();
config.setMapUnderscoreToCamelCase(true);
config.addMapper(LocalLooseboardOrderMapper.class);
var pagination = new MybatisPlusInterceptor();
pagination.addInnerInterceptor(new PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));
config.addInterceptor(pagination);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds); factory.setConfiguration(config);
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LocalLooseboardOrderMapper.class);
repository = new LocalLooseboardRepository();
ReflectionTestUtils.setField(repository, "orders", mapper);
ReflectionTestUtils.setField(repository, "histories", mock(LocalLooseboardHistoryMapper.class));
service = new LocalLooseboardService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "fleetPool", mock(FleetPoolOrderQueries.class));
ReflectionTestUtils.setField(service, "dispatches", mock(LooseboardDispatches.class));
}
/** 审核及资金状态优先,审核通过后内部仓储阶段统一等待排车。 */
@Test
void approvedOrdersWaitForDispatchWhileFundingReviewAndTerminalStatesRemainDistinct() {
String[] statuses = {"FUNDING_PENDING", "FUNDING_FAILED", "PENDING_ACCEPTANCE", "WAITING_INBOUND",
"RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH", "DISPATCHED", "IN_TRANSIT", "COMPLETED", "CANCELLED"};
for (int index = 0; index < statuses.length; index++) order(index + 1, "C1", statuses[index]);
var waiting = service.page("C1", query(1, 20, "PENDING_DISPATCH"));
assertThat(waiting.total()).isEqualTo(4);
assertThat(waiting.records()).allMatch(row -> row.status().equals("PENDING_DISPATCH"));
for (long id : List.of(1L, 2L, 3L, 8L, 9L, 10L, 11L)) {
assertThat(service.merchantDetail("C1", "LB" + id).summary().status()).isEqualTo(statuses[(int) id - 1]);
}
assertThat(service.detail("C1", "LB4").summary().status()).isEqualTo("WAITING_INBOUND");
}
/** 上门取货到仓依次等待排车、等待取货和取货中,到仓后等待下一段排车。 */
@Test
void warehousePickupUsesTripAndPickupFactsWithoutCompletingTheDeliveryEarly() {
order(1, "C1", "WAITING_INBOUND");
assertStatus(1, "PENDING_DISPATCH");
jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES(1,'W1','C1')");
jdbc.update("INSERT INTO ll_fleet_trip VALUES('T1','PLANNED')");
jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'T1','C1','ASSIGNED')");
assertStatus(1, "DISPATCHED");
jdbc.update("UPDATE ll_fleet_trip SET status='CANCELLED'");
assertStatus(1, "PENDING_DISPATCH");
jdbc.update("UPDATE ll_fleet_trip SET status='PLANNED'");
jdbc.update("UPDATE ll_fleet_pickup_task SET trip_no=NULL,status='WAITING'");
assertStatus(1, "PENDING_DISPATCH");
jdbc.update("UPDATE ll_fleet_pickup_task SET trip_no='T1',status='ASSIGNED'");
jdbc.update("UPDATE ll_fleet_trip SET status='IN_TRANSIT'");
assertStatus(1, "PICKING_UP");
jdbc.update("UPDATE ll_fleet_pickup_task SET status='PICKED'");
assertStatus(1, "PICKING_UP");
jdbc.update("UPDATE ll_fleet_pickup_task SET status='ARRIVED'");
assertStatus(1, "PENDING_DISPATCH");
plan(1, "C1"); dispatch(1, 11, "C1", "ACTIVE", "BEFORE_LOADING");
assertStatus(1, "DISPATCHED");
jdbc.update("UPDATE ll_dispatch_order SET stage='DELIVERING'");
assertStatus(1, "IN_TRANSIT");
assertThat(repository.findNo("C1", "LB1").getStatus()).isEqualTo("WAITING_INBOUND");
jdbc.update("UPDATE ll_local_looseboard_order SET status='COMPLETED'");
assertStatus(1, "COMPLETED");
}
/** 取货直送及分车派送按有效排车判断,取消排车和重复托盘关联不污染状态或总数。 */
@Test
void directDeliveryIgnoresCancelledDispatchesAndKeepsSplitDeliveryInTransit() {
order(1, "C1", "WAITING_INBOUND"); plan(1, "C1");
dispatch(1, 11, "C1", "CANCELLED", "DELIVERING");
assertStatus(1, "PENDING_DISPATCH");
dispatch(1, 12, "C1", "ACTIVE", "LOADING");
jdbc.update("INSERT INTO ll_dispatch_item VALUES(1,12,'C1')");
assertStatus(1, "DISPATCHED");
jdbc.update("UPDATE ll_dispatch_order SET stage='DELIVERED' WHERE id=12");
dispatch(1, 13, "C1", "ACTIVE", "BEFORE_LOADING");
assertStatus(1, "IN_TRANSIT");
jdbc.update("UPDATE ll_dispatch_order SET status='CANCELLED'");
assertStatus(1, "PENDING_DISPATCH");
jdbc.update("UPDATE ll_local_looseboard_order SET status='CANCELLED'");
jdbc.update("UPDATE ll_dispatch_order SET status='ACTIVE'");
assertStatus(1, "CANCELLED");
}
/** 原单、来源计划、托盘、排车项及取货任务均保持客户隔离。 */
@Test
void foreignSourceAndPickupFactsCannotAdvanceAnotherCustomersOrder() {
order(1, "C1", "WAITING_INBOUND"); order(2, "C2", "WAITING_INBOUND");
plan(1, "C2"); dispatch(1, 11, "C2", "ACTIVE", "DELIVERING");
assertStatus(1, "PENDING_DISPATCH");
jdbc.update("UPDATE ll_inbound_plan SET customer_id='C1'");
assertStatus(1, "PENDING_DISPATCH");
jdbc.update("UPDATE ll_inbound_plan_pallet SET customer_id='C1'");
assertStatus(1, "PENDING_DISPATCH");
jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES(1,'W1','C2')");
jdbc.update("INSERT INTO ll_fleet_trip VALUES('T1','IN_TRANSIT')");
jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'T1','C2','PICKED')");
assertStatus(1, "PENDING_DISPATCH");
assertThat(repository.merchantStatuses("C2", List.of(1L))).isEmpty();
assertThatThrownBy(() -> service.merchantDetail("C2", "LB1"))
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
jdbc.update("UPDATE ll_dispatch_item SET customer_id='C1'");
assertStatus(1, "IN_TRANSIT");
}
/** 筛选发生在分页之前;筛选总数、列表状态及详情一致并拒绝废弃筛选值。 */
@Test
void merchantPaginationCountsProjectedStatusAndRejectsUnusedFilters() {
order(1, "C1", "WAITING_INBOUND"); order(2, "C1", "RECEIVING");
order(3, "C1", "PENDING_ACCEPTANCE"); order(4, "C2", "WAITING_INBOUND");
var first = service.page("C1", query(1, 1, " pending_dispatch "));
var second = service.page("C1", query(2, 1, "PENDING_DISPATCH"));
assertThat(first.total()).isEqualTo(2); assertThat(first.pages()).isEqualTo(2);
assertThat(second.total()).isEqualTo(2);
assertThat(first.records()).hasSize(1); assertThat(second.records()).hasSize(1);
assertThat(first.records().get(0).id()).isNotEqualTo(second.records().get(0).id());
for (var row : List.of(first.records().get(0), second.records().get(0))) {
assertThat(row.customerId()).isEqualTo("C1");
assertThat(row.status()).isEqualTo(service.merchantDetail("C1", row.orderNo()).summary().status());
}
for (var status : List.of("APPROVED", "REJECTED", "PROCESSING", "WAITING_INBOUND", "PENDING_REVIEW")) {
assertThatThrownBy(() -> service.page("C1", query(1, 20, status)))
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
}
}
/** 构造有正式来源标识的原单,取送时间保持本地业务时间。 */
private void order(long id, String customer, String status) {
var order = new LocalLooseboardOrder();
order.setId(id); order.setCustomerId(customer); order.setOrderNo("LB" + id);
order.setTransferNo("W" + id); order.setLastlegOrderId(id + 1000);
order.setOrigin("LA"); order.setDestination("ONT"); order.setDeliveryMode("WAREHOUSE_PICKUP");
order.setPickupAt(LocalDateTime.of(2026, 11, 6, 9, 0)); order.setDeliveryAt(order.getPickupAt().plusHours(5));
order.setTotalWeightKg(BigDecimal.ZERO); order.setTotalVolumeCbm(BigDecimal.ZERO);
order.setCurrency("USD"); order.setStatus(status); order.setIdempotencyKey("K" + id); order.setRequestHash("0".repeat(64));
order.setCreatedAt(LocalDateTime.of(2026, 11, 6, 8, 0));
repository.insertOrder(order);
}
/** 建立原单到申报托盘的正式链路。 */
private void plan(long id, String customer) {
jdbc.update("INSERT INTO ll_inbound_plan VALUES(?,?,?)", id, id + 1000, customer);
jdbc.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,?)", id, id, customer);
}
/** 建立可独立取消或推进的分车排车事实。 */
private void dispatch(long pallet, long id, String customer, String status, String stage) {
jdbc.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, status, stage);
jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,?,?)", pallet, id, customer);
}
/** 每个节点同时核对列表筛选总数和客户详情。 */
private void assertStatus(long id, String status) {
assertThat(service.merchantDetail("C1", "LB" + id).summary().status()).isEqualTo(status);
var page = service.page("C1", query(1, 20, status));
assertThat(page.total()).isEqualTo(1);
assertThat(page.records().get(0).orderNo()).isEqualTo("LB" + id);
}
/** 用同一路线和日期边界查询客户运输进度。 */
private LocalLooseboardRequests.MerchantQuery query(long page, long size, String status) {
return new LocalLooseboardRequests.MerchantQuery(page, size, null, "LA", status,
LocalDate.of(2026, 11, 6), LocalDate.of(2026, 11, 6));
}
}
@@ -47,13 +47,24 @@ class LocalLooseboardServiceTest {
@Mock LocalLooseboardOperationMapper operations; @Mock LocalLooseboardOperationMapper operations;
@Mock LastlegOrderMapper lastlegOrders; @Mock LastlegOrderMapper lastlegOrders;
@Mock LastlegOrderStatusHistoryMapper lastlegHistories; @Mock LastlegOrderStatusHistoryMapper lastlegHistories;
@Mock com.baiyue.lastleg.looseboard.port.LooseboardDispatches dispatches;
@Mock com.baiyue.lastleg.wallet.budget.OrderBudgetService budgets;
@Spy LastlegOrderNoGenerator lastlegOrderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders(); @Spy LastlegOrderNoGenerator lastlegOrderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders();
private LocalLooseboardService service; private LocalLooseboardService service;
private final Map<String, LocalLooseboardOperation> operationRows = new HashMap<>(); private final Map<String, LocalLooseboardOperation> operationRows = new HashMap<>();
/** 注入真实仓储组合及客户隔离的排车查询端口。 */
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new LocalLooseboardService(); service = new LocalLooseboardService();
ReflectionTestUtils.setField(service, "orderBudget", budgets);
org.mockito.Mockito.lenient().when(budgets.submit(anyString(),anyString(),anyString(),any(),anyString(),any(),any()))
.thenAnswer(call -> ((java.util.function.Supplier<?>)call.getArgument(5)).get());
org.mockito.Mockito.lenient().when(budgets.update(anyString(),anyString(),anyString(),any(),anyString(),any()))
.thenAnswer(call -> ((java.util.function.Supplier<?>)call.getArgument(5)).get());
org.mockito.Mockito.lenient().when(budgets.withOrder(anyString(),anyString(),anyString(),any()))
.thenAnswer(call -> ((java.util.function.Supplier<?>)call.getArgument(3)).get());
ReflectionTestUtils.setField(service, "dispatches", dispatches);
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class)); org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
var repository = new LocalLooseboardRepository(); var repository = new LocalLooseboardRepository();
ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "repository", repository);
@@ -77,6 +88,19 @@ class LocalLooseboardServiceTest {
}); });
} }
/** 无车队预订的普通散板也返回全部排车,客户和原单必须原样限定。 */
@Test
void detailIncludesAllDispatchesWithoutFleetBooking() {
when(orders.findNo("100", "LB1")).thenReturn(order(LocalLooseboardService.IN_TRANSIT, 1L));
when(dispatches.looseboardDispatches("100", "LB1")).thenReturn(List.of("PC1", "PC2"));
var detail = service.detail("100", "LB1");
assertEquals(List.of("PC1", "PC2"), detail.dispatchNos());
org.junit.jupiter.api.Assertions.assertNull(detail.summary().fleetArrangement());
verify(dispatches).looseboardDispatches("100", "LB1");
}
@Test @Test
void createIsTenantScopedIdempotentAndStartsPendingAcceptance() { void createIsTenantScopedIdempotentAndStartsPendingAcceptance() {
AtomicReference<LocalLooseboardOrder> saved = new AtomicReference<>(); AtomicReference<LocalLooseboardOrder> saved = new AtomicReference<>();
@@ -90,7 +114,7 @@ class LocalLooseboardServiceTest {
var detail = service.create("100", "7", create("key-1")); var detail = service.create("100", "7", create("key-1"));
assertEquals("100", detail.summary().customerId()); assertEquals("100", detail.summary().customerId());
assertEquals(LocalLooseboardService.PENDING_ACCEPTANCE, detail.summary().status()); assertEquals(LocalLooseboardService.FUNDING_PENDING, detail.summary().status());
assertEquals("Alice", detail.summary().originContactName()); assertEquals("Alice", detail.summary().originContactName());
assertEquals("Bob", detail.summary().destinationContactName()); assertEquals("Bob", detail.summary().destinationContactName());
assertEquals("家居用品", detail.summary().productName()); assertEquals("家居用品", detail.summary().productName());
@@ -53,6 +53,18 @@ class LastlegOrderSailingStageTest {
assertEquals(List.of(3L, 4L, 7L, 8L, 9L), find(db, null, SailingStage.AFTER_DEPARTURE, null)); assertEquals(List.of(3L, 4L, 7L, 8L, 9L), find(db, null, SailingStage.AFTER_DEPARTURE, null));
assertEquals(List.of(3L, 4L, 7L, 9L), find(db, "A", SailingStage.AFTER_DEPARTURE, null)); assertEquals(List.of(3L, 4L, 7L, 9L), find(db, "A", SailingStage.AFTER_DEPARTURE, null));
assertEquals(List.of(3L, 7L, 9L), find(db, "A", SailingStage.AFTER_DEPARTURE, "PENDING_ASSIGNMENT")); assertEquals(List.of(3L, 7L, 9L), find(db, "A", SailingStage.AFTER_DEPARTURE, "PENDING_ASSIGNMENT"));
// 去重仓储执行真实Mapper,沿用客户隔离和人工覆盖后的实际开船事实。
sql.execute("ALTER TABLE ll_order ADD source_order_no VARCHAR(64)");
sql.execute("UPDATE ll_order SET source_order_no=CONCAT('SOURCE-',id)");
var config = new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
config.addMapper(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);
var factory = new com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean();
factory.setDataSource(new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:sailing;MODE=MySQL", "", ""));
factory.setConfiguration(config);
var mapper = new org.mybatis.spring.SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);
assertEquals(java.util.Set.of("A:SOURCE-3", "A:SOURCE-4", "A:SOURCE-7", "A:SOURCE-9"),
new com.baiyue.lastleg.repository.PreDepartureOrderRepository(mapper).departedKeys("A"));
assertEquals(java.util.stream.LongStream.rangeClosed(1, 14).boxed().toList(), find(db, null, null, null)); // Discard applications have their own workflow and are excluded. assertEquals(java.util.stream.LongStream.rangeClosed(1, 14).boxed().toList(), find(db, null, null, null)); // Discard applications have their own workflow and are excluded.
} }
} }
@@ -79,6 +79,7 @@ class LastlegOrderServiceTest {
@Mock WarehouseResourceGateway warehouseResourceGateway; @Mock WarehouseResourceGateway warehouseResourceGateway;
@Mock ContainerShippingGateMapper containerShippingGates; @Mock ContainerShippingGateMapper containerShippingGates;
@Mock OrderPreDepartureClient preDeparture; @Mock OrderPreDepartureClient preDeparture;
@Mock com.baiyue.lastleg.repository.PreDepartureOrderRepository preDepartureOrders;
@Mock ReturnOrderReferenceResolver returnOrderReferences; @Mock ReturnOrderReferenceResolver returnOrderReferences;
@Mock com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService returnReceipts; @Mock com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService returnReceipts;
LastlegOrderService service; LastlegOrderService service;
@@ -118,6 +119,27 @@ class LastlegOrderServiceTest {
assertEquals(new java.math.BigDecimal("22"), summary.netWeightKg()); assertEquals(new java.math.BigDecimal("22"), summary.netWeightKg());
} }
/** 已清关来源即使订单服务仍返回开船前记录,也应先去重再分页。 */
@Test void excludesDepartedSourcesBeforePagination() {
when(preDepartureOrders.departedKeys("101")).thenReturn(java.util.Set.of("101:EXXHK2611050001"));
var departed = preDepartureRow(1L, "EXXHK2611050001");
var waiting = preDepartureRow(2L, "WAITING-2");
when(preDeparture.page(any(), eq(101L), any(), any(), eq(1), eq(2000)))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(departed, waiting), 1, 2000, 2, 1)));
var query = new LastlegOrderRequests.Query(1L, 1L, null, null, null, null, null, null,
null, null, null, null, null, null, null, null,
LastlegOrderRequests.SailingStage.BEFORE_DEPARTURE, null, null, null);
var page = service.page("101", query);
assertEquals(1L, page.total());
assertEquals(1L, page.pages());
assertEquals("WAITING-2", page.records().get(0).sourceOrderNo());
}
private OrderPreDepartureClient.Row preDepartureRow(long id, String no) {
return new OrderPreDepartureClient.Row(id,101L,no,"IN-"+id,"WAIT_SPLIT", "客户",null,null,null,
null,2,null,null,null,null,null,null,null,null,null,null);
}
@BeforeEach @BeforeEach
void setUp() { void setUp() {
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo( com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
@@ -144,6 +166,7 @@ class LastlegOrderServiceTest {
ReflectionTestUtils.setField(service, "warehouseResourceGateway", warehouseResourceGateway); ReflectionTestUtils.setField(service, "warehouseResourceGateway", warehouseResourceGateway);
ReflectionTestUtils.setField(service, "containerShippingGates", containerShippingGates); ReflectionTestUtils.setField(service, "containerShippingGates", containerShippingGates);
ReflectionTestUtils.setField(service, "orderPreDepartureClient", preDeparture); ReflectionTestUtils.setField(service, "orderPreDepartureClient", preDeparture);
ReflectionTestUtils.setField(service, "preDepartureOrders", preDepartureOrders);
lenient().when(preDeparture.page(any(), any(), any(), any(), anyInt(), anyInt())) lenient().when(preDeparture.page(any(), any(), any(), any(), anyInt(), anyInt()))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(), 1, 2000, 0, 0))); .thenReturn(ApiResponse.success(new PageResult<>(List.of(), 1, 2000, 0, 0)));
} }
@@ -53,6 +53,18 @@ class OrderBudgetAuthorizationTest {
verify(service).retryOrder("9","trucking","KC1"); verify(service).retryOrder("9","trucking","KC1");
assertThatThrownBy(()->controller.retry(writer,"procurement","CG1")).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(()->controller.retry(writer,"procurement","CG1")).isInstanceOf(AccessDeniedException.class);
} }
@Test void looseboardFundingUsesOnlyItsOwnCustomerAndPermissions() {
var controller=context.getBean(MerchantOrderBudgetController.class);
var service=context.getBean(OrderBudgetService.class);
var reader=login(9,"MERCHANT","merchant:cross-border:pallet-shipping:list");
controller.readOrder(reader,"looseboard","LB1");verify(service).readOrder("9","looseboard","LB1");
assertThatThrownBy(()->controller.retry(reader,"looseboard","LB1")).isInstanceOf(AccessDeniedException.class);
var writer=login(9,"MERCHANT","merchant:cross-border:pallet-shipping:create");
controller.retry(writer,"looseboard","LB1");verify(service).retryOrder("9","looseboard","LB1");
assertThatThrownBy(()->controller.retry(writer,"trucking","LB1")).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->controller.readOrder(writer,"procurement","LB1")).isInstanceOf(AccessDeniedException.class);
}
private AuthenticatedUser login(long customer,String app,String permission) { private AuthenticatedUser login(long customer,String app,String permission) {
var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission)); var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(permission))));return user; SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(permission))));return user;
@@ -12,6 +12,11 @@ import java.time.LocalDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.UUID; import java.util.UUID;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import com.baiyue.lastleg.looseboard.mapper.*;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.baiyue.lastleg.looseboard.service.LocalLooseboardService;
import com.baiyue.lastleg.looseboard.port.LooseboardReceiving;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
/** 用真实 Mapper 验证客户隔离、持久化及到期/终态释放候选。 */ /** 用真实 Mapper 验证客户隔离、持久化及到期/终态释放候选。 */
@@ -31,7 +36,7 @@ class OrderBudgetPersistenceTest {
var outbox = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_event_outbox \\(.*?;").matcher(sql); var outbox = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_event_outbox \\(.*?;").matcher(sql);
assertThat(outbox.find()).isTrue();jdbc.execute(outbox.group()); assertThat(outbox.find()).isTrue();jdbc.execute(outbox.group());
jdbc.execute("CREATE TABLE ll_overseas_procurement_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))"); jdbc.execute("CREATE TABLE ll_overseas_procurement_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))");
for (String table : new String[]{"ll_us_truck_order", "ll_us_truck_order_pallet", "ll_us_truck_order_operation", "ll_us_truck_order_status_history"}) { for (String table : new String[]{"ll_us_truck_order", "ll_us_truck_order_pallet", "ll_us_truck_order_operation", "ll_us_truck_order_status_history", "ll_local_looseboard_order", "ll_local_looseboard_operation", "ll_local_looseboard_history"}) {
var ddl = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS " + table + " \\(.*?;").matcher(sql); var ddl = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS " + table + " \\(.*?;").matcher(sql);
assertThat(ddl.find()).isTrue(); jdbc.execute(ddl.group()); assertThat(ddl.find()).isTrue(); jdbc.execute(ddl.group());
} }
@@ -41,6 +46,9 @@ class OrderBudgetPersistenceTest {
config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper.class); config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper.class);
config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper.class); config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper.class);
config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper.class); config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper.class);
config.addMapper(LocalLooseboardOrderMapper.class);
config.addMapper(LocalLooseboardOperationMapper.class);
config.addMapper(LocalLooseboardHistoryMapper.class);
var factory = new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); var factory = new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
sessions = new SqlSessionTemplate(factory.getObject()); sessions = new SqlSessionTemplate(factory.getObject());
repository = new OrderBudgetRepository(sessions.getMapper(OrderBudgetMapper.class)); repository = new OrderBudgetRepository(sessions.getMapper(OrderBudgetMapper.class));
@@ -60,10 +68,11 @@ class OrderBudgetPersistenceTest {
} }
/** 两类业务创建失败时,真实预算、订单及Outbox必须整体回滚。 */ /** 两类业务创建失败时,真实预算、订单及Outbox必须整体回滚。 */
@Test void bothBusinessesRollbackOrderBudgetAndOutboxTogether() { @Test void bothBusinessesRollbackOrderBudgetAndOutboxTogether() {
for (String kind : new String[]{"procurement","trucking"}) { for (String kind : new String[]{"procurement","trucking","looseboard"}) {
String table = "procurement".equals(kind) ? "ll_overseas_procurement_order" : "ll_us_truck_order"; String table = switch(kind) { case "procurement" -> "ll_overseas_procurement_order"; case "trucking" -> "ll_us_truck_order"; default -> "ll_local_looseboard_order"; };
assertThatThrownBy(() -> service.submit("9",kind,"rollback",BigDecimal.TEN,"CNY",() -> { assertThatThrownBy(() -> service.submit("9",kind,"rollback",BigDecimal.TEN,"CNY",() -> {
if ("trucking".equals(kind)) jdbc.update("INSERT INTO ll_us_truck_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(1,'9','FAIL','A','B','FUNDING_PENDING','rollback',?)", "a".repeat(64)); if ("trucking".equals(kind)) jdbc.update("INSERT INTO ll_us_truck_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(1,'9','FAIL','A','B','FUNDING_PENDING','rollback',?)", "a".repeat(64));
else if ("looseboard".equals(kind)) jdbc.update("INSERT INTO ll_local_looseboard_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(1,'9','FAIL','A','B','FUNDING_PENDING','rollback',?)", "a".repeat(64));
else jdbc.update("INSERT INTO " + table + " VALUES('9','FAIL','FUNDING_PENDING')"); else jdbc.update("INSERT INTO " + table + " VALUES('9','FAIL','FUNDING_PENDING')");
throw new IllegalStateException("订单明细写入失败"); throw new IllegalStateException("订单明细写入失败");
}, value -> String.valueOf(value))).hasMessageContaining("订单明细写入失败"); }, value -> String.valueOf(value))).hasMessageContaining("订单明细写入失败");
@@ -74,12 +83,113 @@ class OrderBudgetPersistenceTest {
} }
/** 成功受理原子提交并可同键重放,资金指令不能重复。 */ /** 成功受理原子提交并可同键重放,资金指令不能重复。 */
@Test void acceptsAndReplaysWithoutDuplicateFreeze() { @Test void acceptsAndReplaysWithoutDuplicateFreeze() {
for (String kind : new String[]{"procurement","trucking"}) { for (String kind : new String[]{"procurement","trucking","looseboard"}) {
service.submit("9",kind,"accept",BigDecimal.TEN,"CNY",()->"ORDER1",v->v); service.submit("9",kind,"accept",BigDecimal.TEN,"CNY",()->"ORDER1",v->v);
service.submit("9",kind,"accept",BigDecimal.TEN,"CNY",()->"ORDER1",v->v); service.submit("9",kind,"accept",BigDecimal.TEN,"CNY",()->"ORDER1",v->v);
assertThat(repository.lockOrder("9",kind,"ORDER1").getStatus()).isEqualTo("FREEZE_PENDING"); assertThat(repository.lockOrder("9",kind,"ORDER1").getStatus()).isEqualTo("FREEZE_PENDING");
} }
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(3);
}
/** 散板原单、资金回执、审核和释放使用真实Mapper,重复请求不重复占款或建履约。 */
@Test void looseboardCreationFreezesAndApprovalReleasesTheSameBudget() {
var harness=looseboards();var looseboards=harness.orders();var request=looseboardRequest();
var created=looseboards.create("9","customer",request);
String number=created.summary().orderNo();
assertThat(created.summary().status()).isEqualTo("FUNDING_PENDING");
assertThatThrownBy(()->looseboards.review("9",number,"reviewer",new LocalLooseboardRequests.Review(0L,"APPROVE","early-review",null)))
.hasMessageContaining("冻结成功");
looseboards.create("9","customer",request);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(1);
var budget=repository.lockOrder("9","looseboard",number);
service.handle(truckReceipt(budget,"FREEZE","FROZEN"));
var ready=looseboards.detail("9",number);
assertThat(ready.summary().status()).isEqualTo("PENDING_ACCEPTANCE");
var review=new LocalLooseboardRequests.Review(ready.summary().version(),"APPROVE","review-key",null);
assertThat(looseboards.review("9",number,"reviewer",review).summary().status()).isEqualTo("WAITING_INBOUND");
looseboards.review("9",number,"reviewer",review);
org.mockito.Mockito.verify(harness.receiving(),org.mockito.Mockito.times(1)).approve(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.eq("reviewer"));
budget=repository.lockOrder("9","looseboard",number);
assertThat(budget.getStatus()).isEqualTo("RELEASE_PENDING");assertThat(budget.getReleaseRequested()).isTrue();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2);
service.handle(truckReceipt(budget,"RELEASE","RELEASED"));
assertThat(repository.lockOrder("9","looseboard",number).getStatus()).isEqualTo("RELEASED");
assertThat(looseboards.create("9","customer",request).summary().orderNo()).isEqualTo(number);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2);
}
/** 冻结失败不能审核,重试仍绑定原单;驳回同键重放只释放一次。 */
@Test void looseboardInsufficientFundsCanRetryAndRejectionReleasesOnce() {
var harness=looseboards();var looseboards=harness.orders();
String number=looseboards.create("9","customer",looseboardRequest()).summary().orderNo();
var budget=repository.lockOrder("9","looseboard",number);
service.handle(new com.baiyue.common.event.WalletCommandCompletedV1(budget.getCommandEventId(),"FREEZE",9L,OrderBudgetService.BUSINESS_TYPE,
budget.getId().toString(),"FAILED",null,budget.getAmountCny(),null,"可用余额和授信额度不足",java.time.Instant.now(),null,null));
assertThat(looseboards.detail("9",number).summary().status()).isEqualTo("FUNDING_FAILED");
assertThatThrownBy(()->looseboards.review("9",number,"reviewer",new LocalLooseboardRequests.Review(1L,"APPROVE","early",null)))
.hasMessageContaining("冻结成功");
assertThat(service.readOrder("9","looseboard",number).message()).contains("授信额度不足");
service.retryOrder("9","looseboard",number);
budget=repository.lockOrder("9","looseboard",number);service.handle(truckReceipt(budget,"FREEZE","FROZEN"));
var review=new LocalLooseboardRequests.Review(looseboards.detail("9",number).summary().version(),"REJECT","reject-key","路线不可承运");
looseboards.review("9",number,"reviewer",review);looseboards.review("9",number,"reviewer",review);
assertThat(looseboards.detail("9",number).summary().status()).isEqualTo("CANCELLED");
assertThat(repository.lockOrder("9","looseboard",number).getStatus()).isEqualTo("RELEASE_PENDING");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(3);
org.mockito.Mockito.verifyNoInteractions(harness.receiving());
}
/** 取消时冻结在途,成功回执只能触发释放,不能将原单重新改成待审核。 */
@Test void looseboardCancellationReleasesLateFreezeWithoutReopeningOrder() {
var looseboards=looseboards().orders();String number=looseboards.create("9","customer",looseboardRequest()).summary().orderNo();
var cancel=new LocalLooseboardRequests.ChangeStatus(0L,"CANCELLED","cancel-key",null);
looseboards.changeStatus("9",number,"operator",cancel);looseboards.changeStatus("9",number,"operator",cancel);
var budget=repository.lockOrder("9","looseboard",number);assertThat(budget.getReleaseRequested()).isTrue();
service.handle(truckReceipt(budget,"FREEZE","FROZEN"));
assertThat(repository.lockOrder("9","looseboard",number).getStatus()).isEqualTo("RELEASE_PENDING");
assertThat(looseboards.detail("9",number).summary().status()).isEqualTo("CANCELLED");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2);
}
/** 审核履约创建失败回滚,不提前释放预算或保存已审核状态。 */
@Test void looseboardApprovalFailureKeepsOriginalHold() {
var harness=looseboards();var looseboards=harness.orders();String number=looseboards.create("9","customer",looseboardRequest()).summary().orderNo();
var budget=repository.lockOrder("9","looseboard",number);service.handle(truckReceipt(budget,"FREEZE","FROZEN"));
org.mockito.Mockito.when(harness.receiving().approve(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.anyString())).thenThrow(new IllegalStateException("履约创建失败"));
assertThatThrownBy(()->looseboards.review("9",number,"reviewer",new LocalLooseboardRequests.Review(1L,"APPROVE","failed-review",null)))
.hasMessageContaining("履约创建失败");
assertThat(looseboards.detail("9",number).summary().status()).isEqualTo("PENDING_ACCEPTANCE");
assertThat(repository.lockOrder("9","looseboard",number).getStatus()).isEqualTo("FROZEN");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(1);
}
private record Looseboards(LocalLooseboardService orders,LooseboardReceiving receiving) {}
/** 散板原单及历史使用生产持久化,仅隔离真正的收货履约入口。 */
private Looseboards looseboards() {
var orders=sessions.getMapper(LocalLooseboardOrderMapper.class);var history=sessions.getMapper(LocalLooseboardHistoryMapper.class);
var repo=new com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository();
ReflectionTestUtils.setField(repo,"orders",orders);ReflectionTestUtils.setField(repo,"histories",history);
ReflectionTestUtils.setField(repo,"operations",sessions.getMapper(LocalLooseboardOperationMapper.class));
var looseboards=new LocalLooseboardService();ReflectionTestUtils.setField(looseboards,"repository",repo);
ReflectionTestUtils.setField(looseboards,"objectMapper",new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
ReflectionTestUtils.setField(looseboards,"orderBudget",service);
ReflectionTestUtils.setField(looseboards,"dispatches",org.mockito.Mockito.mock(com.baiyue.lastleg.looseboard.port.LooseboardDispatches.class));
ReflectionTestUtils.setField(looseboards,"fleetPool",org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
var numbers=org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegOrderNoGenerator.class);
org.mockito.Mockito.when(numbers.next("LOOSEBOARD")).thenReturn("LB-BUDGET-1");ReflectionTestUtils.setField(looseboards,"lastlegOrderNoGenerator",numbers);
var receiving=org.mockito.Mockito.mock(LooseboardReceiving.class);ReflectionTestUtils.setField(looseboards,"receiving",receiving);
org.mockito.Mockito.when(receiving.approve(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.anyString())).thenReturn(new LooseboardReceiving.Created("TRANSFER1",99L));
var actualFunding=new com.baiyue.lastleg.procurement.funding.OrderFundingService(
new com.baiyue.lastleg.procurement.funding.OrderFundingRepository(null,null,null,null,orders,history));
org.mockito.Mockito.doAnswer(call->{actualFunding.apply(call.getArgument(0),call.getArgument(1),call.getArgument(2));return null;})
.when(funding).apply(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any());
return new Looseboards(looseboards,receiving);
}
/** 明确金额和币种,避免测试把未填金额冒充零价。 */
private LocalLooseboardRequests.Create looseboardRequest() {
return new LocalLooseboardRequests.Create("9","FBA",null,"WAREHOUSE_PICKUP",null,null,null,null,"LA","Street A","ONT8",null,
"货物",2,10,BigDecimal.TEN,BigDecimal.ONE,new BigDecimal("100.00"),"CNY","发货人","10086","收货人","10010",
LocalDateTime.of(2026,11,2,9,0),LocalDateTime.of(2026,11,3,9,0),"原始备注","looseboard-budget-key");
} }
/** 本地真实建卡车单,核对冻结、审核释放、回执和重复审核的持久化结果。 */ /** 本地真实建卡车单,核对冻结、审核释放、回执和重复审核的持久化结果。 */
@@ -99,7 +209,7 @@ class OrderBudgetPersistenceTest {
org.mockito.Mockito.when(numbers.next("TRUCK")).thenReturn("TRUCK-LOCAL-BUDGET-1"); org.mockito.Mockito.when(numbers.next("TRUCK")).thenReturn("TRUCK-LOCAL-BUDGET-1");
org.springframework.test.util.ReflectionTestUtils.setField(trucks, "orderNoGenerator", numbers); org.springframework.test.util.ReflectionTestUtils.setField(trucks, "orderNoGenerator", numbers);
var actualFunding = new com.baiyue.lastleg.procurement.funding.OrderFundingService( var actualFunding = new com.baiyue.lastleg.procurement.funding.OrderFundingService(
new com.baiyue.lastleg.procurement.funding.OrderFundingRepository(null, null, orders, histories)); new com.baiyue.lastleg.procurement.funding.OrderFundingRepository(null, null, orders, histories, null, null));
org.mockito.Mockito.doAnswer(call -> { org.mockito.Mockito.doAnswer(call -> {
actualFunding.apply(call.getArgument(0), call.getArgument(1), call.getArgument(2)); return null; actualFunding.apply(call.getArgument(0), call.getArgument(1), call.getArgument(2)); return null;
}).when(funding).apply(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); }).when(funding).apply(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
@@ -158,7 +268,11 @@ class OrderBudgetPersistenceTest {
repository.insert(row(4,"FROZEN")); repository.insert(row(4,"FROZEN"));
repository.insert(row(5,"FREEZE_PENDING")); repository.insert(row(5,"FREEZE_PENDING"));
var recentFailure = row(6,"RELEASE_FAILED");recentFailure.setReleaseRequested(true);repository.insert(recentFailure); var recentFailure = row(6,"RELEASE_FAILED");recentFailure.setReleaseRequested(true);repository.insert(recentFailure);
assertThat(repository.releasable()).extracting(OrderBudget::getId).containsExactly(1L,2L,3L); var acceptedLooseboard=row(7,"FROZEN");acceptedLooseboard.setKind("looseboard");acceptedLooseboard.setOrderNo("LB1");repository.insert(acceptedLooseboard);
jdbc.update("INSERT INTO ll_local_looseboard_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(7,'9','LB1','A','B','WAITING_INBOUND','accepted',?)", "a".repeat(64));
var pendingLooseboard=row(8,"FROZEN");pendingLooseboard.setKind("looseboard");pendingLooseboard.setOrderNo("LB2");repository.insert(pendingLooseboard);
jdbc.update("INSERT INTO ll_local_looseboard_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(8,'9','LB2','A','B','PENDING_ACCEPTANCE','pending',?)", "a".repeat(64));
assertThat(repository.releasable()).extracting(OrderBudget::getId).containsExactly(1L,2L,3L,7L);
} }
/** 测试数据与生产预占使用同一 UTC 时间,数据库会话保持东八区以覆盖时区差异。 */ /** 测试数据与生产预占使用同一 UTC 时间,数据库会话保持东八区以覆盖时区差异。 */
private OrderBudget row(long id,String status) { private OrderBudget row(long id,String status) {
@@ -68,7 +68,7 @@ class OrderBudgetServiceTest {
assertThat(service.read("9","procurement","key").usable()).isFalse(); assertThat(service.read("9","procurement","key").usable()).isFalse();
assertThat(service.read("9","procurement","key").message()).contains("过期"); assertThat(service.read("9","procurement","key").message()).contains("过期");
} }
@ParameterizedTest @ValueSource(strings={"procurement","trucking"}) @ParameterizedTest @ValueSource(strings={"procurement","trucking","looseboard"})
void reviewReleaseImmediatelyReleasesFrozenBudget(String kind) { void reviewReleaseImmediatelyReleasesFrozenBudget(String kind) {
row.setKind(kind); row.setKind(kind);
row.setOrderNo("CG-1"); row.setStatus("FROZEN"); row.setOrderNo("CG-1"); row.setStatus("FROZEN");
@@ -15,6 +15,24 @@ class OrderFundingServiceTest {
OrderFundingRepository repository = mock(OrderFundingRepository.class); OrderFundingRepository repository = mock(OrderFundingRepository.class);
OrderFundingService service = new OrderFundingService(repository); OrderFundingService service = new OrderFundingService(repository);
/** 散板成功后进入原待审核状态,失败可重试;重复或取消后的回执不复活原单。 */
@Test void looseboardFundingPreservesAuditGateAndIgnoresLateResults() {
var budget = budget("looseboard");
var order = new com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder();
order.setId(1L);order.setCustomerId("9");order.setStatus("FUNDING_PENDING");order.setVersion(0L);order.setRemark("客户备注");
when(repository.looseboard("9","NO1")).thenReturn(order);
service.apply(budget,"FUNDING_FAILED","可用余额和授信额度不足");
assertThat(order.getStatus()).isEqualTo("FUNDING_FAILED");
service.apply(budget,"FUNDING_PENDING",null);
service.apply(budget,"PENDING_REVIEW",null);
service.apply(budget,"PENDING_REVIEW",null);
assertThat(order.getStatus()).isEqualTo("PENDING_ACCEPTANCE");
assertThat(order.getVersion()).isEqualTo(3L);assertThat(order.getRemark()).isEqualTo("客户备注");
order.setStatus("CANCELLED");service.apply(budget,"PENDING_REVIEW",null);
assertThat(order.getStatus()).isEqualTo("CANCELLED");
verify(repository,times(3)).history(eq(order),anyString(),anyString(),nullable(String.class),any());
}
/** 采购冻结成功后才待审核,重复成功无第二条历史。 */ /** 采购冻结成功后才待审核,重复成功无第二条历史。 */
@Test void purchaseSuccessIsIdempotentAndPreservesRemark() { @Test void purchaseSuccessIsIdempotentAndPreservesRemark() {
var budget = budget("procurement"); var budget = budget("procurement");
@@ -136,7 +136,8 @@ public class UnifiedOrderQueryService {
OrderEntity order = authority.get(row.orderId()); OrderEntity order = authority.get(row.orderId());
if (order == null || !Objects.equals(order.getTenantId(), row.tenantId()) if (order == null || !Objects.equals(order.getTenantId(), row.tenantId())
|| !ACTIVE_ORDER_STATUSES.contains(order.getStatus())) continue; || !ACTIVE_ORDER_STATUSES.contains(order.getStatus())) continue;
String status = order.getStatus() == OrderStatus.SUBMITTED ? "PENDING_REVIEW" // 待审核主状态仍由 Order 决定,提前测量提交产生的独立审核任务须保留。
String status = order.getStatus() == OrderStatus.SUBMITTED && !"MEASUREMENT_REVIEW".equals(row.mainStatus()) ? "PENDING_REVIEW"
: order.getStatus() == OrderStatus.COMPLETED && !WORKFLOW_STATUSES.contains(row.mainStatus()) ? "COMPLETED" : row.mainStatus(); : order.getStatus() == OrderStatus.COMPLETED && !WORKFLOW_STATUSES.contains(row.mainStatus()) ? "COMPLETED" : row.mainStatus();
if (status != null) result.add(new FirstlegUnifiedOrderClient.StatusRow(row.tenantId(), row.orderId(), status)); if (status != null) result.add(new FirstlegUnifiedOrderClient.StatusRow(row.tenantId(), row.orderId(), status));
} }
@@ -421,7 +421,7 @@ paths:
需要平台身份和 platform:order:list;tenantId 省略时按授权平台范围查询全部客户。 需要平台身份和 platform:order:list;tenantId 省略时按授权平台范围查询全部客户。
按统一列表非分页、非 mainStatus 筛选全量去重;忽略主状态和分页,不返回全部数量。 按统一列表非分页、非 mainStatus 筛选全量去重;忽略主状态和分页,不返回全部数量。
订单审核与完成状态优先于履约投影,依赖异常返回业务错误,不能以零代替失败。 订单审核与完成状态优先于履约投影,依赖异常返回业务错误,不能以零代替失败。
MEASUREMENT_REVIEW 为独立任务,暂不返回该 key。 MEASUREMENT_REVIEW 返回独立测量审核任务数量;待审核订单提前测量并提交审核后可同时计入 PENDING_REVIEW 与 MEASUREMENT_REVIEW。
parameters: parameters:
- $ref: '#/components/parameters/UnifiedTaxMode' - $ref: '#/components/parameters/UnifiedTaxMode'
- {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -1743,7 +1743,7 @@ components:
name: mainStatus name: mainStatus
in: query in: query
required: false required: false
description: 统一订单主状态及业务页签筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询。WAIT_CUSTOMS_DOCS 为实际装柜后待上传客户报关资料;EXPORT_CUSTOMS 为本票报关资料已上传且实际装柜完成、尚未报关完成或实际开船的汇总,买单票无需客户退税资料,不以点击开始报关为前提;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;生成出库单后进入 OUTBOUND_DOCUMENT,实际出库后进入 DELIVERING,派送完成进入 SIGNED。 description: 统一订单主状态及业务页签筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询。WAIT_CUSTOMS_DOCS 为实际装柜后待上传客户报关资料;EXPORT_CUSTOMS 为本票报关资料已上传且实际装柜完成、尚未报关完成或实际开船的汇总,买单票无需客户退税资料,不以点击开始报关为前提;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;尾程排车理货审核通过并生成有效出库单后进入 OUTBOUND_DOCUMENT,不以前置开船节点或清关资料为条件;取消或实际出库后移出,实际出库并发车后进入 DELIVERING,派送完成进入 SIGNED。
schema: schema:
type: string type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW] enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW]
@@ -1012,6 +1012,35 @@ class UnifiedOrderQueryServiceTest {
}); });
} }
/** 提前测量提交后同时保留订单待审核和测量审核待办,统计与列表一致。 */
@Test
void submittedOrderRetainsIndependentMeasurementReviewTask() {
var pending = order(OrderStatus.SUBMITTED);
when(orders.unifiedStatusOrders(eq(10001L), any(), any())).thenReturn(List.of(pending));
when(orders.statusAuthorities(eq(10001L), any())).thenReturn(List.of(pending));
when(orders.findByIds(eq(10001L), any())).thenReturn(List.of(pending));
when(firstleg.statusRows(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of(
new FirstlegUnifiedOrderClient.StatusRow(10001L, 77L, "WAIT_MEASURE"),
new FirstlegUnifiedOrderClient.StatusRow(10001L, 77L, "MEASUREMENT_REVIEW"),
new FirstlegUnifiedOrderClient.StatusRow(10001L, 77L, "MEASUREMENT_REVIEW"))));
var projected = summary("WAIT_MEASURE", null);
when(projected.inbound().processStatus()).thenReturn("MEASURE_PENDING");
when(firstleg.statusSummaries(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(List.of(projected)));
var counts = service.statusCounts(10001L, query(null));
var page = service.page(10001L, query("MEASUREMENT_REVIEW"));
assertThat(counts.counts()).containsEntry("PENDING_REVIEW", 1L)
.containsEntry("MEASUREMENT_REVIEW", 1L).containsEntry("WAIT_MEASURE", 0L);
assertThat(page.total()).isEqualTo(1L);
assertThat(page.records()).singleElement().satisfies(row -> {
assertThat(row.mainStatus()).isEqualTo("PENDING_REVIEW");
assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER");
assertThat(row.inboundId()).isEqualTo(88L);
});
}
/** 错误响应和跨客户投影均失败关闭,不能返回看似可信的零。 */ /** 错误响应和跨客户投影均失败关闭,不能返回看似可信的零。 */
@Test @Test
void statusCountsRejectDependencyFailuresAndForeignCustomerRows() { void statusCountsRejectDependencyFailuresAndForeignCustomerRows() {
+1
View File
@@ -647,3 +647,4 @@ Admin 全量中的 `phase4-cross-region-events` 使用内部 `test-event-fault-e
固定角色/账号统一配置:TEST 使用显式环境文件中的 `ADMIN_TEST_FIXED_ACTORS` JSON 对象,GRAY 通用入口使用 `ADMIN_GRAY_FIXED_ACTORS`,GRAY 拼车和页面样本使用 `CHONGCHONG_ACCEPTANCE_FIXED_ACTORS`,不跨环境借用。槽位和字段见模块索引 `fixed-test-actors.mjs`;密码仅留在私有环境文件及明确的私有页面交接文件中。GRAY PDA 账号通过 `CHONGCHONG_ACCEPTANCE_PDA_ACTORS` 传递到专用入口(通用入口为 `ADMIN_GRAY_PDA_ACTORS`);只读核验并复用已有仓绑定账号。当前 Admin HTTP 客户端拒绝 POST 新增角色、账号、权限和供应商,所有上下文均不例外。角色权限不足、账号停用或供应商缺失均停止,不自动创建、启用、重置或扩大权限。拼车 HTTP 和成本探针完成后仅退出会话;页面固定账号保留原状态,不停用。 固定角色/账号统一配置:TEST 使用显式环境文件中的 `ADMIN_TEST_FIXED_ACTORS` JSON 对象,GRAY 通用入口使用 `ADMIN_GRAY_FIXED_ACTORS`,GRAY 拼车和页面样本使用 `CHONGCHONG_ACCEPTANCE_FIXED_ACTORS`,不跨环境借用。槽位和字段见模块索引 `fixed-test-actors.mjs`;密码仅留在私有环境文件及明确的私有页面交接文件中。GRAY PDA 账号通过 `CHONGCHONG_ACCEPTANCE_PDA_ACTORS` 传递到专用入口(通用入口为 `ADMIN_GRAY_PDA_ACTORS`);只读核验并复用已有仓绑定账号。当前 Admin HTTP 客户端拒绝 POST 新增角色、账号、权限和供应商,所有上下文均不例外。角色权限不足、账号停用或供应商缺失均停止,不自动创建、启用、重置或扩大权限。拼车 HTTP 和成本探针完成后仅退出会话;页面固定账号保留原状态,不停用。
本机 `local-dispatch-transport-acceptance.mjs`、`local-transfer-pickup-acceptance.mjs` 也只复用配置的平台账号和 `LOCAL_TRANSFER_PDA_USERNAME/PASSWORD`,不再补权限、创建操作员角色或PDA账号;权限不足即停止。Client HTTP 客户端同样拒绝新增客户角色、成员与权限。 本机 `local-dispatch-transport-acceptance.mjs`、`local-transfer-pickup-acceptance.mjs` 也只复用配置的平台账号和 `LOCAL_TRANSFER_PDA_USERNAME/PASSWORD`,不再补权限、创建操作员角色或PDA账号;权限不足即停止。Client HTTP 客户端同样拒绝新增客户角色、成员与权限。
`local-mixed-looseboard-workflow-acceptance.mjs`:仅本机统一 Gateway 的六单混排 HTTP 验收,三单送货到仓分两托实收共九箱、三单异地上门取货共六箱。使用显式开发配置的 `ADMIN_TEST_*` 与 `MIXED_PDA_USERNAME/MIXED_PDA_PASSWORD`(密码可复用显式 `LOCAL_TRANSFER_PDA_PASSWORD`),例如 `node --env-file=<开发配置> --env-file=<PDA配置> scripts/local-mixed-looseboard-workflow-acceptance.mjs`。会写客户测试原单、排车、专用测试车及司机和合成照片;默认报告 `reports/local-mixed-looseboard-workflow.json`,`MIXED_ACCEPTANCE_OUTPUT` 可覆盖,`MIXED_ACCEPTANCE_PHASE=prepare` 停在审核后。默认 `MIXED_ACCEPTANCE_SCENARIO=multi-return` 一次选择同派送两张原单各退一箱,生成退回单、计划并由PDA实收,重放核对库存仅恢复一次,剩余七箱保存签收。异常实签实退核平后调用正式车次结案入口;账号缺少结案权限时报告 `pendingAction`,不得声称结案或资源已释放。`MIXED_ACCEPTANCE_SCENARIO=normal` 配合新的独立报告路径,验证全部九箱正常签收及自动结案释放。回仓通过正式资源接口创建两张本轮托盘并绑定既有有效库位,不复用已报废的原出库托。只复用既有客户/PDA权限,不直接写数据库,不删除数据,续跑沿报告原单和幂等键恢复,未知写结果停写。HTTP 通过不代表 PDA 真机扫码、实物测量或真实凭证验收。
+14 -2
View File
@@ -69,7 +69,8 @@ case "$command" in
esac esac
` `
async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag, allowRollback = true, environment = 'test', service = 'audit-service', missingHealth = false, topicPrefix = `${environment}-`, remote = false }) { // 通过临时 Docker/SSH 替身验证环境选择、健康门禁及失败后的镜像恢复。
async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag, allowRollback = true, environment = 'test', service = 'audit-service', missingHealth = false, missingTestLimits = false, topicPrefix = `${environment}-`, remote = false }) {
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'firstleg-rollback-')) const temporaryDirectory = await mkdtemp(join(tmpdir(), 'firstleg-rollback-'))
const mockBin = join(temporaryDirectory, 'bin') const mockBin = join(temporaryDirectory, 'bin')
const dockerPath = join(mockBin, 'docker') const dockerPath = join(mockBin, 'docker')
@@ -115,7 +116,7 @@ cp "$1" "\${2#*:}"
service, service,
envFile.replaceAll('\\', '/') envFile.replaceAll('\\', '/')
], { ], {
cwd: repositoryRoot, cwd: missingTestLimits ? temporaryDirectory : repositoryRoot,
encoding: 'utf8', encoding: 'utf8',
env: { env: {
...process.env, ...process.env,
@@ -158,6 +159,16 @@ test('successful deployment keeps the new healthy image', async (context) => {
assert.match(result.stdout, /部署前运行镜像: registry\.local\/group\/firstleg\/audit-service:oldgood/) assert.match(result.stdout, /部署前运行镜像: registry\.local\/group\/firstleg\/audit-service:oldgood/)
assert.match(result.stdout, /新镜像健康检查通过: registry\.local\/group\/firstleg\/audit-service:newgood/) assert.match(result.stdout, /新镜像健康检查通过: registry\.local\/group\/firstleg\/audit-service:newgood/)
assert.equal(result.currentImage, 'registry.local/group/firstleg/audit-service:newgood') assert.equal(result.currentImage, 'registry.local/group/firstleg/audit-service:newgood')
for (const command of result.dockerLog.split(/\r?\n/).filter(line => line.startsWith('compose '))) {
assert.match(command, /-f deploy\/docker-compose\.yml -f deploy\/docker-compose\.test-limits\.yml /)
}
})
test('TEST rejects a missing memory limits file before Docker', async () => {
const result = await runDeployment({ imageTag: 'newgood', missingTestLimits: true })
assert.equal(result.status, 2)
assert.equal(result.dockerLog, '')
assert.match(result.stderr, /TEST memory limits file missing/)
}) })
test('unhealthy deployment restores the previous healthy image and still fails the job', async (context) => { test('unhealthy deployment restores the previous healthy image and still fails the job', async (context) => {
@@ -232,6 +243,7 @@ test('gray deploy preserves health checks and uses an isolated Compose project',
assert.equal(result.status, 0, result.stderr) assert.equal(result.status, 0, result.stderr)
assert.match(result.dockerLog, /--project-name firstleg-production/) assert.match(result.dockerLog, /--project-name firstleg-production/)
assert.doesNotMatch(result.dockerLog, /--project-name firstleg-test/) assert.doesNotMatch(result.dockerLog, /--project-name firstleg-test/)
assert.doesNotMatch(result.dockerLog, /docker-compose\.test-limits\.yml/)
}) })
test('gray deployment rejects TEST message scope before any Docker operation', async () => { test('gray deployment rejects TEST message scope before any Docker operation', async () => {
@@ -0,0 +1,299 @@
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { createHash } from 'node:crypto'
import { prepareFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
const gateway = process.env.ADMIN_TEST_GATEWAY?.replace(/\/$/, '')
if (!gateway || !['localhost', '127.0.0.1'].includes(new URL(gateway).hostname)) throw Error('必须显式指定本机 Gateway')
const output = resolve(process.env.MIXED_ACCEPTANCE_OUTPUT || 'reports/local-mixed-looseboard-workflow.json')
const scenario = process.env.MIXED_ACCEPTANCE_SCENARIO || 'multi-return'
if (!['multi-return', 'normal'].includes(scenario)) throw Error('验收场景仅支持 multi-return 或 normal')
let report
try { report = JSON.parse(await readFile(output, 'utf8')) } catch (error) { if (error.code !== 'ENOENT') throw error }
report ||= { marker: `MIXLB-${Date.now()}`, environment: 'LOCAL', syntheticEvidence: true, orders: [], operations: {}, checks: [], createdResources: {} }
if (report.scenario && report.scenario !== scenario || !report.scenario && report.orders.length && scenario !== 'multi-return') throw Error('不同场景必须使用独立验收报告')
report.scenario = scenario
const root = '/api/platform/v1/last-leg', pr = '/api/pda/v1/last-leg'
const key = name => createHash('sha256').update(`${report.marker}:${name}`).digest('hex')
let admin, pda, merchant
/** 原子保存写请求和回执,失败留单续跑,不删除或重复造单。 */
async function save() { await mkdir(dirname(output), { recursive: true }); await writeFile(`${output}.tmp`, JSON.stringify(report, null, 2)); await rename(`${output}.tmp`, output) }
/** 断言保留业务含义,失败立即停止后续写入。 */
function check(value, label) { report.checks.push({ label, passed: Boolean(value) }); if (!value) throw Error(label) }
/** 统一Gateway真实HTTP;会话凭据不写入报告。 */
async function raw(method, path, body, token = admin) {
const form = body instanceof FormData
const response = await fetch(gateway + path, { method, signal: AbortSignal.timeout(45000), headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body !== undefined && !form ? { 'Content-Type': 'application/json' } : {}), ...(path.endsWith('/exception-orders/return-inbound-plans') ? { 'Idempotency-Key': key(`return-plan-${body.plan.orderId}`) } : {}) }, body: body === undefined ? undefined : form ? body : JSON.stringify(body) })
const value = await response.json()
return { http: response.status, ...value, requestId: value.requestId || response.headers.get('x-request-id'), traceId: value.traceId || response.headers.get('x-trace-id') }
}
/** 仅读显式配置的现有账号,不新建或修改权限。 */
async function login(username, password) {
if (!username || !password) throw Error('缺少显式验收账号配置')
const result = await raw('POST', '/api/auth/login', { loginId: username, password }, '')
if (String(result.code) !== '200') throw Error(`登录失败:${result.code} ${result.message}`)
return result.data.accessToken
}
/** 每条命令前读取当前服务端事实,禁止自行推算版本。 */
async function get(path, token = admin) {
const result = await raw('GET', path, undefined, token)
if (result.http !== 200 || String(result.code) !== '200') throw Error(`GET ${path}: ${result.http}/${result.code} ${result.message}`)
return result.data
}
/** 原步骤成功回执可恢复;未知写结果停写,保留原幂等键供核对。 */
async function write(name, method, path, body, token = admin) {
if (report.operations[name]?.status === 'SUCCESS') return report.operations[name].result
if (report.unknownWrite) throw Error(`上次写结果未知:${report.unknownWrite}`)
const entry = { method, path, body: body instanceof FormData ? 'SYNTHETIC_TEST_IMAGE' : body, status: 'SENDING' }
report.operations[name] = entry; await save()
let result
try { result = await raw(method, path, body, token) } catch (error) { entry.status = 'UNKNOWN'; report.unknownWrite = name; await save(); throw error }
Object.assign(entry, { http: result.http, code: result.code, requestId: result.requestId, traceId: result.traceId, message: result.message })
entry.status = result.http === 200 && String(result.code) === '200' ? 'SUCCESS' : result.http >= 500 || result.code == null ? 'UNKNOWN' : 'FAILED'
if (entry.status === 'UNKNOWN') report.unknownWrite = name
if (entry.status === 'SUCCESS') entry.result = result.data
await save()
if (entry.status !== 'SUCCESS') throw Error(`${method} ${path}: ${result.http}/${result.code} ${result.message}`)
return result.data
}
/** 合成小图仅证明文件协议和归属,不能代表现场物流证据。 */
async function photo(name, path, token = admin) {
const form = new FormData(); form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')], { type: 'image/png' }), `${report.marker}-${name}-SYNTHETIC-TEST.png`)
return write(name, 'POST', path, form, token)
}
/** 三箱分两托实收,订单展示应汇总一行,PDA沿用真实托盘明细。 */
async function receive(order) {
const path = `${pr}/warehouse-transfers/${order.transferNo}`, label = order.orderNo
let d = await get(path, pda)
if (d.status === 'READY_FOR_DISPATCH') return
while (d.pallets.length < 2) { await write(`${label}-draft-${d.pallets.length}`, 'POST', `${path}/pallets/drafts`, { idempotencyKey: key(`${label}-draft-${d.pallets.length}`) }, pda); d = await get(path, pda) }
for (const initial of d.pallets) {
let p = d.pallets.find(row => row.id === initial.id)
if (p.status === 'DRAFT') await write(`${label}-generate-${p.id}`, 'POST', `${path}/pallets/${p.id}/generate`, { idempotencyKey: key(`${label}-generate-${p.id}`) }, pda)
d = await get(path, pda); p = d.pallets.find(row => row.id === initial.id)
if (p.status === 'GENERATED') await write(`${label}-bind-${p.id}`, 'POST', `${path}/pallets/${p.id}/bind`, { idempotencyKey: key(`${label}-bind-${p.id}`), palletCode: p.palletNo }, pda)
d = await get(path, pda)
}
if (!d.cartons.every(row => row.countedAt)) await write(`${label}-count`, 'POST', `${path}/cartons/count`, { idempotencyKey: key(`${label}-count`), forecastPackageCount: d.packageCount,
boxes: d.cartons.map((row, i) => ({ cartonMark: row.cartonMark, transferPalletId: d.pallets[i === 0 ? 0 : 1].id, length: 40, width: 30, height: 25, weight: 4, items: [{ skuCode: d.items[0].skuCode, quantity: 1 }] })) }, pda)
const f = await photo(`${label}-measurement`, `${path}/measurement-photos/files`, pda)
await write(`${label}-palletize`, 'POST', `${path}/palletize`, { idempotencyKey: key(`${label}-palletize`), photoFileIds: [f.fileId] }, pda)
d = await get(path, pda); check(d.status === 'READY_FOR_DISPATCH' && d.pallets.length === 2, `${label}三箱两托实收完成`); order.receiving = d; await save()
}
/** 六张本轮原单:三单到仓实收,三单保留异地上门取货。 */
async function prepare() {
const customers = (await get('/api/platform/v1/iam/tenants?page=1&size=200')).records
report.customerId ||= customers.find(row => row.status === 'ACTIVE' && /测试/.test(row.tenantName || row.name))?.id
report.warehouseId ||= (await get('/api/platform/v1/warehouse-admin?page=1&size=100&warehouseType=OVERSEAS&status=ACTIVE')).records.find(row => row.warehouseCode === 'US-LAX-01')?.id
check(report.customerId && report.warehouseId, '复用有效测试客户和洛杉矶仓'); await save()
const ticket = await raw('POST', `/api/platform/v1/iam/tenants/${report.customerId}/impersonation`, { reason: `${report.marker}本机混排验收` })
check(String(ticket.code) === '200', '获取本轮客户下单会话')
merchant = (await raw('POST', '/api/auth/impersonations/exchange', { code: ticket.data.code }, '')).data.accessToken
for (const mode of ['DELIVERY_TO_WAREHOUSE', 'WAREHOUSE_PICKUP']) for (let i = 0; i < 3; i++) {
const label = `${mode}-${i}`, remote = mode === 'WAREHOUSE_PICKUP'
let order = report.orders.find(row => row.label === label)
if (!order) {
const created = await write(label, 'POST', '/api/merchant/v1/last-leg/local-looseboard-orders', { operatingWarehouseId: report.warehouseId, destinationType: 'PRIVATE', destinationPostalCode: '91761', deliveryMode: mode,
deliveryContactName: '合成验收联系人', deliveryContactPhone: '0000000000', estimatedArrivalAt: '2026-11-06T09:00:00', trackingNo: `${report.marker}-${label}`,
origin: 'Ontario TEST', originAddress: `100 TEST Pickup Road ${i}`, destination: 'Ontario TEST', destinationAddress: '200 TEST Destination Road', productName: `${report.marker}-${label}`,
estimatedPalletCount: 2, packageCount: remote ? 2 : 3, totalWeightKg: remote ? 8 : 12, totalVolumeCbm: remote ? 0.06 : 0.09, budgetAmount: 25, currency: 'USD',
originContactName: '合成验收取货员', originContactPhone: '0000000000', destinationContactName: '合成验收收货员', destinationContactPhone: '0000000000',
pickupAt: '2026-11-06T09:00:00', deliveryAt: '2026-11-07T09:00:00', remark: `${report.marker} SYNTHETIC TEST`, idempotencyKey: key(label) }, merchant)
order = { label, mode, orderNo: created.summary.orderNo }; report.orders.push(order); await save()
}
const path = `${root}/local-looseboard-orders/${order.orderNo}`, query = `?customerId=${report.customerId}`
let d = await get(path + query)
if (d.summary.status === 'PENDING_ACCEPTANCE') await write(`${label}-review`, 'POST', `${path}/review${query}`, { version: d.summary.version, decision: 'APPROVE', idempotencyKey: key(`${label}-review`), remark: report.marker })
d = await get(path + query); order.transferNo = d.summary.transferNo; await save(); check(order.transferNo, `${order.orderNo}审核生成真实履约来源`)
if (!remote && !order.receiving) await receive(order)
}
if (!report.dispatchNo) { report.dispatchNo = (await write('create-dispatch', 'POST', `${root}/dispatches`, { idempotencyKey: key('create-dispatch'), warehouseId: report.warehouseId, publishLooseboardDemand: true, cargo: [], batchAllocations: [], remark: `${report.marker} 仓内九箱+异地六箱` })).dispatchNo; await save() }
const path = `${root}/dispatches/${report.dispatchNo}`, cp = `${root}/dispatches/capacity-demands/${report.dispatchNo}/cargo`
if (!report.operations['add-cargo']?.result) {
const selected = (await get(`${cp}?page=1&size=50`)).candidates.filter(row => report.orders.some(order => row.inboundCode === order.transferNo || row.orderNo === order.orderNo || row.sourceBusinessNo === order.orderNo))
check(selected.length >= 6 && selected.every(row => !row.blockReasons.length), '六单均为正式可选来源')
const sources = new Map(selected.flatMap(row => row.selectionGroup.sources).map(row => [row.sourceKey, row.selection]))
await write('add-cargo', 'POST', cp, { recordVersion: (await get(path)).recordVersion, idempotencyKey: key('add-cargo'), cargo: [...sources.values()], batchAllocations: [] })
}
report.preparedDetail = await get(path); await save()
check(Number(report.preparedDetail.counts.packageCount) === 15, '混排十五箱:仓内九箱、异地六箱')
check(report.preparedDetail.orderDetails.length === 6, '关联订单按六张散板原单汇总')
if (!report.fleet) {
const context = { marker: report.marker, options: {}, createdResources: report.createdResources, checkpoint: save,
request: async (method, url, body, token) => { const r = await raw(method, url, body, token || admin); return { http: r.http, body: r } },
requestMultipart: async (method, url, file) => { const f = new FormData(); f.append('file', new Blob([file.content], { type: file.type }), file.name); const r = await raw(method, url, f); return { http: r.http, body: r } },
apiSuccess: (module, label, response) => { check(response.http === 200 && String(response.body.code) === '200', `${label}: ${response.body.message}`); return response.body.data }, assert: (module, label, response, passed) => check(passed, label) }
report.fleet = await prepareFleetTrip(context, 'local-mixed-looseboard', report.dispatchNo, report.warehouseId); await save()
}
}
/** PDA只拣仓内九箱;异地六箱不进入拣货校验和仓内出库。 */
async function pickAndLoad() {
const path = `${pr}/dispatches/${report.dispatchNo}`
let task = await get(path, pda); report.pdaBefore ||= task; await save()
check(Number(task.counts.packageCount) === 9 && task.items.every(row => row.positions?.length), 'PDA只要求仓内九箱')
for (const initial of task.items) {
task = await get(path, pda); let item = task.items.find(row => row.dispatchItemId === initial.dispatchItemId)
for (const position of item.positions) {
const packages = Math.min(Number(position.availablePackages), Number(item.scheduledPackages) - Number(item.pickedPackages))
if (packages <= 0) continue
const name = `pick-${item.dispatchItemId}-${position.positionId}`
const prep = await write(`${name}-rent`, 'POST', `${pr}/storage-rent/preparations`, { mode: 'DISPATCH', businessNo: report.dispatchNo, itemId: item.dispatchItemId, positionId: position.positionId, packages, productQuantity: packages, idempotencyKey: key(name) }, pda)
check(prep.ready === true, '本轮新入库货物仓租预校验就绪')
await write(name, 'POST', `${path}/items/${item.dispatchItemId}/pick`, { recordVersion: task.recordVersion, positionId: position.positionId, packages, productQuantity: packages, idempotencyKey: key(name), remark: report.marker }, pda)
task = await get(path, pda); item = task.items.find(row => row.dispatchItemId === initial.dispatchItemId)
}
}
task = await get(path, pda); report.pdaPicked = task; await save(); check(task.pickingReconciled && Number(task.counts.pickedPackageCount) === 9, '九箱实际拣货核平')
if (task.stage === 'LOADING') { const loading = await get(`${path}/loading`, pda); await write('loading-complete', 'POST', `${path}/loading-complete`, { recordVersion: loading.recordVersion, idempotencyKey: key('loading-complete'), manifestHash: loading.manifestHash, remark: report.marker }, pda) }
report.afterLoading = await get(`${root}/dispatches/${report.dispatchNo}`); await save(); check(report.afterLoading.stage === 'DELIVERING', '仓内装车完成立即发运,不等待异地取货')
}
/** 上门取货三单各存一组取货/送达照片,分托不增加资料块。 */
async function remoteDocuments() {
const path = `${root}/dispatches/${report.dispatchNo}`
let docs = await get(`${path}/loading-documents`)
for (const slot of ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT']) {
if (docs.photos.some(row => row.slot === slot)) continue
const name = `loading-photo-${slot}`, uploadKey = key(`${name}-upload`)
const f = await photo(name, `${path}/loading-documents/${slot}/uploads?reviewRound=${docs.reviewRound}&idempotencyKey=${uploadKey}`)
await write(`${name}-bind`, 'PUT', `${path}/loading-documents/${slot}`, { recordVersion: docs.recordVersion, idempotencyKey: key(`${name}-bind`), fileId: f.fileId, uploadKey })
docs = await get(`${path}/loading-documents`)
}
report.loadingDocuments = docs; check(docs.complete, '五类装车资料补齐')
let execution = await get(`${path}/looseboard-execution`)
report.looseboardBefore ||= execution; await save()
const items = execution.items.filter(row => row.pickupRequired)
check(items.length === 3 && new Set(items.map(row => row.orderNo)).size === 3, '上门取货按三张订单展示资料,六个计划托不拆六块')
for (const phase of ['PICKUP', 'ARRIVAL']) {
if (phase === 'ARRIVAL') {
execution = await get(`${path}/looseboard-execution`)
if (!execution.pickupCompleted) await write('pickup-complete', 'POST', `${path}/looseboard-execution/pickup-complete`, { recordVersion: execution.recordVersion, idempotencyKey: key('pickup-complete') })
}
for (const initial of items) {
execution = await get(`${path}/looseboard-execution`)
const row = execution.items.find(item => item.itemId === initial.itemId), photos = phase === 'PICKUP' ? row.pickupPhotos : row.arrivalPhotos
if (photos.length) continue
const name = `${initial.orderNo}-${phase}`, f = await photo(`${name}-upload`, `${path}/looseboard-execution/${initial.itemId}/${phase}/uploads`)
await write(`${name}-save`, 'PUT', `${path}/looseboard-execution/${initial.itemId}/${phase}`, { recordVersion: execution.recordVersion, idempotencyKey: key(`${name}-save`), fileIds: [f.fileId] })
}
}
execution = await get(`${path}/looseboard-execution`)
if (!execution.pickupCompleted) await write('pickup-complete', 'POST', `${path}/looseboard-execution/pickup-complete`, { recordVersion: execution.recordVersion, idempotencyKey: key('pickup-complete') })
report.looseboardAfter = await get(`${path}/looseboard-execution`); await save()
check(report.looseboardAfter.pickupCompleted && report.looseboardAfter.items.filter(row => row.pickupRequired).every(row => row.pickupPhotos.length && row.arrivalPhotos.length), '三单上门取货和送达资料全部完成')
}
/** 一次选择同派送的两张原订单,生成两张异常并沿PDA分批回仓实收。 */
async function exceptionReturns() {
const path = `${root}/dispatches/${report.dispatchNo}`, query = `?customerId=${report.customerId}`
if (!report.operations['multi-order-return']?.result) {
const candidates = await get(`${path}/exception-return-options`)
report.returnOptions = candidates; await save()
const originals = report.orders.filter(row => row.mode === 'DELIVERY_TO_WAREHOUSE').slice(0, 2)
const selected = originals.map(order => candidates.find(row => row.lastMileOrderNo === order.transferNo || row.lastMileOrderNo === order.orderNo))
check(selected.every(Boolean) && selected[0].deliveryNo === selected[1].deliveryNo, '同一派送单下两张原单均可选择')
const returns = selected.map(row => ({ deliveryNo: row.deliveryNo, lastMileOrderId: row.lastMileOrderId,
items: [{ outboundPalletItemId: row.sources[0].outboundPalletItemId, inventoryPositionId: row.sources[0].inventoryPositionId,
returnCartonId: row.sources[0].returnCartonId, packageCount: 1 }] }))
await write('multi-order-return', 'POST', `${path}/exceptions`, { recordVersion: (await get(path)).recordVersion, idempotencyKey: key('multi-order-return'),
exceptionType: 'PARTIAL_RECEIPT', returnDirection: 'OVERSEAS_WAREHOUSE', description: `${report.marker} 两单各退一箱,其余签收`, imageFileIds: [], returnSelections: returns })
}
const rows = (await get(`${root}/exception-orders?page=1&size=100&keyword=${encodeURIComponent(report.marker)}`)).records.filter(row => row.reason?.includes(report.marker))
check(rows.length === 2, '一次多选实际生成两张独立异常')
report.exceptions ||= []
for (const row of rows) {
const name = row.exceptionNo, ep = `${root}/exception-orders/${name}`, pp = `${pr}/return-receipts/exceptions/${name}`
let detail = await get(ep + query)
if (!detail.returnOrderNo) await write(`${name}-generate`, 'POST', `${ep}/generate-return-inbound${query}`, { expectedVersion: detail.order.version, idempotencyKey: key(`${name}-generate`), handlingAction: '合成测试退回海外仓' })
detail = await get(ep + query)
const tails = (await get(`${root}/orders?page=1&size=50&orderNo=${detail.returnOrderNo}`)).records.filter(item => item.orderNo === detail.returnOrderNo)
check(tails.length === 1, `${name}唯一关联退回尾程单`)
const tail = tails[0]
if (!report.operations[`${name}-plan`]?.result) await write(`${name}-plan`, 'POST', `${root}/exception-orders/return-inbound-plans`, { expectedArrivalAt: '2026-11-06T10:00:00',
plan: { customerId: report.customerId, orderId: tail.id, expectedOrderVersion: tail.version, palletCount: 1, palletType: 'STANDARD', assigned: false, mergeRequired: false, urgent: false, remark: report.marker } })
if (detail.order.status !== 'COMPLETED') {
const tasks = await get(`${pr}/return-receipts/exceptions`, pda); check(tasks.some(task => task.exceptionNo === name), `${name}出现在PDA回仓任务`)
const started = await write(`${name}-start`, 'POST', `${pp}/start`, {}, pda)
const original = report.orders.find(order => order.transferNo === row.lastMileOrderNo)
// 原出库托盘可能已报废;回仓另建实际可用托盘,沿用原仓有效虚拟库位。
const sourcePallet = original.receiving.pallets[0]
const resourcePath = `/api/platform/v1/overseas-warehouse-resources/${report.warehouseId}`
const targetId = await write(`${name}-target-pallet`, 'POST', `${resourcePath}/pallets`, {
palletCode: `${report.marker}-${name}`, palletType: 'STANDARD', capacityCbm: 2, unitPrice: 0,
status: 'IDLE', locationId: sourcePallet.warehouseLocationId, remark: `${report.marker} 异常回仓验收托盘` })
const target = await get(`${resourcePath}/pallets/${targetId}`)
check(target.status !== 'SCRAPPED' && target.locationId === sourcePallet.warehouseLocationId, `${name}使用当前仓真实有效托盘`)
const f = await photo(`${name}-evidence`, `${pp}/evidence`, pda)
await write(`${name}-receive`, 'POST', `${pp}/receive`, { expectedVersion: started.order.version, idempotencyKey: key(`${name}-receive`),
palletCode: target.palletCode, locationCode: sourcePallet.warehouseLocationCode, evidenceFileIds: [f.fileId],
items: started.returnSources.map(source => ({ outboundPalletItemId: source.outboundPalletItemId, inventoryPositionId: source.inventoryPositionId, returnCartonId: source.returnCartonId, packages: Number(source.maxReturnPackages) })) }, pda)
}
detail = await get(ep + query)
check(detail.order.status === 'COMPLETED' && detail.returnReceipts.reduce((sum, receipt) => sum + Number(receipt.receivedPackages), 0) === 1, `${name}PDA实收一箱且异常完结`)
const returnedTail = (await get(`${root}/orders?page=1&size=50&orderNo=${detail.returnOrderNo}`)).records.find(item => item.orderNo === detail.returnOrderNo)
check(returnedTail.currentStatus === 'INBOUND_COMPLETED' && Number(returnedTail.receivedPackageCount) === 1, `${name}退回尾程已入库签收`)
const receiptCommand = report.operations[`${name}-receive`]
const replay = await raw(receiptCommand.method, receiptCommand.path, receiptCommand.body, pda)
check(String(replay.code) === '200' && replay.data.replayed, `${name}实收同键重放不重复入账`)
const inventoryPath = `${root}/inventory`
const ledger = await get(`${inventoryPath}/ledger${query}&size=100&businessType=RETURN_STOCK_IN&businessOrderNo=${name}`)
const balances = await get(`${inventoryPath}/balances${query}&size=100&inboundCode=${row.inboundCode}`)
check(ledger.records.reduce((sum, entry) => sum + Number(entry.deltaPackages), 0) === 1, `${name}退回库存流水仅增加一箱`)
check(balances.records.reduce((sum, balance) => sum + Number(balance.availablePackages), 0) === 1, `${name}原单实际可用库存恢复一箱`)
report.inventoryVerification ||= {}; report.inventoryVerification[name] = { ledger, balances, receiptReplay: replay.data }
report.exceptions = [...report.exceptions.filter(item => item.order.exceptionNo !== name), detail]; await save()
}
await finishDelivery(7)
}
/** 签收只记录剩余实际交付量;异常车核平实签实退后通过正式结案入口释放资源。 */
async function finishDelivery(expectedSigned) {
const path = `${root}/dispatches/${report.dispatchNo}`, query = `?customerId=${report.customerId}`
const delivery = report.afterLoading.documents.deliveries[0], dp = `${root}/delivery-orders/${delivery.deliveryNo}`
let documents = await get(`${dp}/documents${query}`)
if (!report.operations['delivery-pod-save']?.result) {
const f = await photo('delivery-pod', `${dp}/documents/RECEIPT/files${query}`)
await write('delivery-pod-save', 'PUT', `${dp}/documents${query}`, { expectedVersion: documents.version, idempotencyKey: key('delivery-pod-save'), bolNo: `${report.marker}-TEST-BOL`,
documents: documents.categories.map(category => ({ category: category.category, fileIds: category.category === 'RECEIPT' ? [f.fileId] : category.files.map(file => file.fileId) })) })
}
report.deliveryFinal = await get(dp + query)
check(report.deliveryFinal.order.status === 'DELIVERED' && Number(report.deliveryFinal.order.signedPackages) === expectedSigned, `仓内实际签收${expectedSigned}箱凭签收文件完结`)
if (scenario === 'normal') {
for (let attempt = 0; attempt < 15; attempt++) {
report.finalDispatch = await get(path)
if (report.finalDispatch.stage === 'DELIVERED') break
await new Promise(resolve => setTimeout(resolve, 1000))
}
check(report.finalDispatch.stage === 'DELIVERED', '全正常混排自动进入派送完成')
}
report.finalDispatch = await get(path); report.finalTrip = await get(`${root}/fleet/trips/${report.fleet.tripNo}/completion`); await save()
if (scenario === 'multi-return' && report.finalTrip.canComplete) {
const previous = report.operations['exception-trip-complete']
if (previous?.code !== 'A0403') {
try {
await write('exception-trip-complete', 'POST', `${root}/fleet/trips/${report.fleet.tripNo}/complete`, {
recordVersion: report.finalTrip.recordVersion, idempotencyKey: key('exception-trip-complete'), reason: `${report.marker} 核实七箱签收两箱实际回仓后结案` })
} catch (error) { if (report.operations['exception-trip-complete']?.code !== 'A0403') throw error }
}
}
report.tripDetail = await get(`${root}/fleet/trips/${report.fleet.tripNo}`)
if (report.operations['exception-trip-complete']?.code === 'A0403') {
report.pendingAction = { action: 'EXCEPTION_TRIP_CLOSE', tripNo: report.fleet.tripNo, reason: '当前验收账号缺少platform:fleet:trip:complete,保留原权限及车次事实,待有权限账号结案。' }
check(report.finalTrip.canComplete && report.finalTrip.outcomes.every(row => row.remainingPackages === 0), '异常车实签实退核平,可由有权限账号结案')
} else check(report.tripDetail.trip.status === 'COMPLETED' && report.tripDetail.trip.vehicleRelease && report.tripDetail.trip.driverRelease, '车次结案并释放车辆及司机')
report.harnessCorrections ||= []
for (const previous of report.checks.filter(row => row.label === '仓内签收和上门取货送达后整车完结')) {
report.harnessCorrections.push({ ...previous, reason: '现有排车规则仅原实发全部正常签收推进DELIVERED;部分签收及实退通过车次正式结案,不能把实退伪作签收。' })
}
report.checks = report.checks.filter(row => row.label !== '仓内签收和上门取货送达后整车完结')
}
try {
admin = await login(process.env.ADMIN_TEST_USERNAME, process.env.ADMIN_TEST_PASSWORD)
pda = await login(process.env.MIXED_PDA_USERNAME, process.env.MIXED_PDA_PASSWORD || process.env.LOCAL_TRANSFER_PDA_PASSWORD)
await prepare()
if (process.env.MIXED_ACCEPTANCE_PHASE !== 'prepare') {
if (!report.afterLoading) await pickAndLoad()
await remoteDocuments()
if (scenario === 'multi-return') await exceptionReturns()
else await finishDelivery(9)
}
report.phase = process.env.MIXED_ACCEPTANCE_PHASE === 'prepare' ? 'PREPARED' : report.pendingAction ? 'COMPLETED_WITH_PENDING_ACTION' : 'COMPLETED'; delete report.error; await save()
console.log(JSON.stringify({ phase: report.phase, dispatchNo: report.dispatchNo, orders: report.orders.map(({ orderNo, mode }) => ({ orderNo, mode })), output }))
} catch (error) { report.error = error.message; await save(); console.error(error.message); process.exitCode = 1 }
@@ -1,5 +1,9 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises' import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { spawnSync } from 'node:child_process'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import test from 'node:test' import test from 'node:test'
const pipeline = (await readFile(new URL('../Jenkinsfile.service', import.meta.url), 'utf8')) const pipeline = (await readFile(new URL('../Jenkinsfile.service', import.meta.url), 'utf8'))
@@ -15,6 +19,60 @@ const serviceDockerfile = (await readFile(new URL('../deploy/docker/Dockerfile',
const serviceCompose = (await readFile(new URL('../deploy/docker-compose.yml', import.meta.url), 'utf8')) const serviceCompose = (await readFile(new URL('../deploy/docker-compose.yml', import.meta.url), 'utf8'))
.replaceAll('\r\n', '\n') .replaceAll('\r\n', '\n')
test('TEST rejects old source commits even when the workspace contains newer limits files', {
skip: process.platform === 'win32' && !existsSync('C:/Program Files/Git/bin/bash.exe')
}, async () => {
const directory = await mkdtemp(join(tmpdir(), 'test-memory-source-'))
const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash'
const guardStart = pipeline.indexOf('if [ "$RESOLVED_ENVIRONMENT" = "test" ]; then')
const guardEnd = pipeline.indexOf('if [ "$RESOLVED_ENVIRONMENT" = "production" ]; then', guardStart)
assert.ok(guardStart >= 0 && guardEnd > guardStart)
assert.ok(guardStart < pipeline.indexOf("stage('Maven 测试与打包')"))
const guard = pipeline.slice(guardStart, guardEnd)
// 在微型 Git 夹具中验证目标提交本身,避免工作区残留文件误放行旧版本。
const git = (...args) => {
const result = spawnSync('git', args, { cwd: directory, encoding: 'utf8' })
assert.equal(result.status, 0, result.stderr)
return result.stdout.trim()
}
// 构建提交沿用正常身份设置,但禁止夹具触发全局签名及提交钩子。
const commit = (message) => {
git('add', '.')
git('-c', 'user.name=Resource Contract', '-c', 'user.email=resource@example.invalid',
'-c', 'core.hooksPath=/dev/null', 'commit', '--no-gpg-sign', '-m', message)
return git('rev-parse', 'HEAD')
}
try {
git('init')
await writeFile(join(directory, 'README.md'), 'Source guard fixture\n')
const oldCommit = commit('old source')
await mkdir(join(directory, 'deploy/scripts'), { recursive: true })
await writeFile(join(directory, 'deploy/docker-compose.test-limits.yml'), 'services: {}\n')
await writeFile(join(directory, 'deploy/scripts/deploy-built-service.sh'), '#!/bin/sh\nexit 0\n')
const incompleteCommit = commit('limits file without deployment integration')
await writeFile(join(directory, 'deploy/scripts/deploy-built-service.sh'), directDeployScript)
const protectedCommit = commit('complete memory protection')
git('checkout', '--detach', oldCommit)
await mkdir(join(directory, 'deploy/scripts'), { recursive: true })
await writeFile(join(directory, 'deploy/docker-compose.test-limits.yml'), 'services: {}\n')
await writeFile(join(directory, 'deploy/scripts/deploy-built-service.sh'), directDeployScript)
for (const [environment, sourceCommit, status] of [
['test', oldCommit, 1], ['test', incompleteCommit, 1],
['test', protectedCommit, 0], ['production', oldCommit, 0]
]) {
const result = spawnSync(bash, ['-c', guard], {
cwd: directory, encoding: 'utf8', timeout: 10000,
env: { ...process.env, RESOLVED_ENVIRONMENT: environment, RESOLVED_SOURCE_COMMIT: sourceCommit }
})
assert.equal(result.status, status, result.stderr)
if (status !== 0) assert.match(result.stderr, /目标 TEST 提交缺少完整内存保护/)
}
} finally {
assert.equal(dirname(resolve(directory)), resolve(tmpdir()))
await rm(directory, { recursive: true, force: true })
}
})
test('both pipelines reject the placeholder registry from failed build 662 before Maven or Docker', () => { test('both pipelines reject the placeholder registry from failed build 662 before Maven or Docker', () => {
for (const source of [coordinatorPipeline, pipeline]) { for (const source of [coordinatorPipeline, pipeline]) {
assert.match(source, /string\(name: 'IMAGE_REPOSITORY', defaultValue: params\.IMAGE_REPOSITORY \?: ''/, assert.match(source, /string\(name: 'IMAGE_REPOSITORY', defaultValue: params\.IMAGE_REPOSITORY \?: ''/,