Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45b5fe8f6f | ||
|
|
cb7d478288 | ||
|
|
d722bcae49 | ||
|
|
44202a7bff | ||
|
|
ef2ac1eafd | ||
|
|
08ea663334 | ||
|
|
8809801070 | ||
|
|
497544e9fd | ||
|
|
00120df2eb | ||
|
|
9af1eba876 |
@@ -125,6 +125,14 @@ pipeline {
|
||||
git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}"
|
||||
git checkout --detach "${RESOLVED_SOURCE_COMMIT}"
|
||||
test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")"
|
||||
# TEST 重发旧提交也须包含限额文件及部署入口,避免重建时恢复无限制容器。
|
||||
if [ "$RESOLVED_ENVIRONMENT" = "test" ]; then
|
||||
if ! git cat-file -e "${RESOLVED_SOURCE_COMMIT}:deploy/docker-compose.test-limits.yml" 2>/dev/null ||
|
||||
! git grep -q -F 'docker-compose.test-limits.yml' "$RESOLVED_SOURCE_COMMIT" -- deploy/scripts/deploy-built-service.sh; then
|
||||
echo "目标 TEST 提交缺少完整内存保护,请选择包含限额修复的 test 提交" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ "$RESOLVED_ENVIRONMENT" = "production" ]; then
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
git merge-base --is-ancestor "$RESOLVED_SOURCE_COMMIT" origin/main || {
|
||||
|
||||
@@ -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"
|
||||
@@ -12,6 +12,7 @@ IMAGE_TAG="$3"
|
||||
SERVICE="$4"
|
||||
ENV_FILE="$5"
|
||||
COMPOSE_FILE="deploy/docker-compose.yml"
|
||||
TEST_LIMITS_FILE="deploy/docker-compose.test-limits.yml"
|
||||
IMAGE="$IMAGE_REPOSITORY/$SERVICE:$IMAGE_TAG"
|
||||
REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}"
|
||||
PROJECT_NAME="firstleg-$DEPLOY_ENV"
|
||||
@@ -40,6 +41,9 @@ case "${ALLOW_SAME_CONTRACT_ROLLBACK:-false}" in true|false) ;; *) exit 2 ;; esa
|
||||
if [ "$DEPLOY_ENV" = "test" ] && [ "${TARGET_IS_LOCAL:-false}" != "true" ]; then
|
||||
echo "TEST deploy must run on the target host" >&2; exit 2
|
||||
fi
|
||||
if [ "$DEPLOY_ENV" = "test" ] && [ ! -f "$TEST_LIMITS_FILE" ]; then
|
||||
echo "TEST memory limits file missing: $TEST_LIMITS_FILE" >&2; exit 2
|
||||
fi
|
||||
[ -f "$ENV_FILE" ] || { echo "Environment file missing" >&2; exit 2; }
|
||||
env_file_value() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'; }
|
||||
for key in NACOS_SERVER_ADDR NACOS_NAMESPACE NACOS_USERNAME NACOS_PASSWORD REDIS_HOST REDIS_PORT REDIS_DATABASE REDIS_TOKEN_KEY_PREFIX INTERNAL_SERVICE_TOKEN JWT_RSA_JWK_SET JWT_CURRENT_KID ROCKETMQ_NAME_SERVER; do
|
||||
@@ -74,7 +78,11 @@ local_registry_logout() {
|
||||
if [ -n "${deploy_docker_config:-}" ]; then rm -rf -- "$deploy_docker_config"; fi
|
||||
}
|
||||
|
||||
# TEST 的查询、部署和回滚共用同一份内存限额,缺失时由入口提前拒绝。
|
||||
local_compose() {
|
||||
if [ "$DEPLOY_ENV" = "test" ]; then
|
||||
set -- -f "$TEST_LIMITS_FILE" "$@"
|
||||
fi
|
||||
docker compose \
|
||||
--project-name "$PROJECT_NAME" \
|
||||
--env-file "$ENV_FILE" \
|
||||
|
||||
@@ -49,6 +49,7 @@ enum BusinessFileCategory {
|
||||
OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_CUSTOMER_VISIT(true, FormatPolicy.CUSTOMER_VISIT, Limits.CUSTOMER_VISIT, BusinessIdPolicy.POSITIVE_LONG),
|
||||
LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||
LOADING_LIST_CUSTOMS_RECEIPT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||
LOADING_LIST_CUSTOMS_RESULT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||
@@ -135,11 +136,12 @@ enum BusinessFileCategory {
|
||||
return Long.toString(value);
|
||||
}
|
||||
|
||||
enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF, PDF_IMAGE, IMAGE }
|
||||
enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF, PDF_IMAGE, IMAGE, CUSTOMER_VISIT }
|
||||
private enum BusinessIdPolicy { POSITIVE_LONG, ORDER_BATCH_NO }
|
||||
private static final class Limits {
|
||||
private static final long STANDARD = 10L * 1024 * 1024;
|
||||
private static final long CUSTOMS = 50L * 1024 * 1024;
|
||||
private static final long ORDER_IMPORT = 5L * 1024 * 1024;
|
||||
private static final long CUSTOMER_VISIT = 20L * 1024 * 1024;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
@@ -27,6 +28,8 @@ public class BusinessFileStorage {
|
||||
private static final Map<String, String> TYPES = Map.of(
|
||||
"text/plain", ".txt", "application/pdf", ".pdf",
|
||||
"image/jpeg", ".jpg", "image/png", ".png");
|
||||
private static final Set<String> CUSTOMER_VISIT_EXTENSIONS = Set.of(
|
||||
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "jpg", "jpeg", "png", "webp");
|
||||
|
||||
@Value("${baiyue.file.storage-root}")
|
||||
private Path configuredRoot;
|
||||
@@ -46,6 +49,7 @@ public class BusinessFileStorage {
|
||||
return store(tenantId, businessId, file, category.maxSizeBytes(), category.formatPolicy());
|
||||
}
|
||||
|
||||
/** 按业务类别检查大小和格式,保存真实文件并返回存储结果。 */
|
||||
private Stored store(long tenantId, String businessId, MultipartFile file, long maxSize,
|
||||
BusinessFileCategory.FormatPolicy formatPolicy) {
|
||||
if (file == null || file.isEmpty()) throw bad("附件不能为空");
|
||||
@@ -55,6 +59,8 @@ public class BusinessFileStorage {
|
||||
String extension;
|
||||
if (formatPolicy == BusinessFileCategory.FormatPolicy.ARBITRARY) {
|
||||
extension = "";
|
||||
} else if (formatPolicy == BusinessFileCategory.FormatPolicy.CUSTOMER_VISIT) {
|
||||
extension = customerVisitExtension(file);
|
||||
} else if (formatPolicy == BusinessFileCategory.FormatPolicy.PDF) {
|
||||
if (!"application/pdf".equals(type)) throw bad("附件仅支持PDF");
|
||||
verifyContent(file, type);
|
||||
@@ -85,6 +91,17 @@ public class BusinessFileStorage {
|
||||
}
|
||||
}
|
||||
|
||||
/** 与回访入口保持相同扩展名约束,接受浏览器未提供 MIME 的 Office 资料。 */
|
||||
private static String customerVisitExtension(MultipartFile file) {
|
||||
String name = safeName(file);
|
||||
int dot = name.lastIndexOf('.');
|
||||
String extension = dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT);
|
||||
if (!CUSTOMER_VISIT_EXTENSIONS.contains(extension)) {
|
||||
throw bad("仅支持PDF、Office、JPG、PNG和WEBP文件");
|
||||
}
|
||||
return "." + extension;
|
||||
}
|
||||
|
||||
private static void verifyXlsx(MultipartFile file) {
|
||||
try (InputStream input = file.getInputStream()) {
|
||||
byte[] prefix = input.readNBytes(4);
|
||||
|
||||
@@ -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("归属不匹配");
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -87,11 +87,12 @@ public class PlatformFbaPricingController {
|
||||
return ApiResponse.success(service.confirm(scope, user.userId(), inboundId, idempotencyKey));
|
||||
}
|
||||
|
||||
/** 按费用发送权限发布已确认费用,审核门禁由服务端校验且不推进履约状态。 */
|
||||
@PostMapping("/send")
|
||||
@PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
|
||||
+ " 'firstleg:inbound:send-fee')")
|
||||
@BusinessAudit(event = "FBA_FEE_SEND", resourceType = "INBOUND", resourceId = "#inboundId",
|
||||
description = "发送FBA费用快照并进入待托盘状态")
|
||||
description = "发送FBA费用快照")
|
||||
public ApiResponse<FeeSnapshotResponse> send(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
||||
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey) {
|
||||
|
||||
@@ -331,6 +331,7 @@ public class FbaPricingRepository {
|
||||
return inbounds.update(null, update);
|
||||
}
|
||||
|
||||
/** 保存试算及计费量,保留测量、审核与仓库作业的主状态。 */
|
||||
public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode,
|
||||
java.math.BigDecimal selectedWeightKg,
|
||||
java.math.BigDecimal selectedVolumeCbm,
|
||||
@@ -341,10 +342,6 @@ public class FbaPricingRepository {
|
||||
.eq(InboundPricingEntity::getProcessStatus, processStatus)
|
||||
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED", "SENT"))
|
||||
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
|
||||
if (!List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")
|
||||
.contains(processStatus)) {
|
||||
update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING");
|
||||
}
|
||||
applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeMode, chargeable);
|
||||
return inbounds.update(null, update);
|
||||
}
|
||||
@@ -383,12 +380,14 @@ public class FbaPricingRepository {
|
||||
.set(InboundFeeEntity::getConfirmedAt, now));
|
||||
}
|
||||
|
||||
/** 确认费用时保留物流主状态,一件代发待组托也允许确认。 */
|
||||
/** 仅确认已通过测量审核的费用,并保留当前履约状态。 */
|
||||
public int markInboundConfirmed(long tenantId, long inboundId) {
|
||||
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
||||
.in(InboundPricingEntity::getProcessStatus,
|
||||
List.of("FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"))
|
||||
List.of("WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT",
|
||||
"LOADING", "LOADED", "DELIVERED"))
|
||||
.eq(InboundPricingEntity::getMeasureReviewStatus, "APPROVED")
|
||||
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
|
||||
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
|
||||
}
|
||||
@@ -436,15 +435,14 @@ public class FbaPricingRepository {
|
||||
.set(InboundPricingEntity::getFeeStatus, "VOIDED"));
|
||||
}
|
||||
|
||||
/** 发送仅更新费用状态,并原子校验测量已审核及履约状态未变化。 */
|
||||
public int markInboundFeeSent(long tenantId, long inboundId, String processStatus) {
|
||||
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
||||
.eq(InboundPricingEntity::getProcessStatus, processStatus)
|
||||
.eq(InboundPricingEntity::getMeasureReviewStatus, "APPROVED")
|
||||
.eq(InboundPricingEntity::getFeeStatus, "CONFIRMED")
|
||||
.set(InboundPricingEntity::getFeeStatus, "SENT");
|
||||
if ("FEE_CONFIRMING".equals(processStatus)) {
|
||||
update.set(InboundPricingEntity::getProcessStatus, "WAIT_PALLET");
|
||||
}
|
||||
return inbounds.update(null, update);
|
||||
}
|
||||
|
||||
|
||||
@@ -196,9 +196,8 @@ public class FbaPricingService {
|
||||
}
|
||||
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
|
||||
complete(token, fee.getId(), now);
|
||||
String nextStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus())
|
||||
? inbound.getProcessStatus() : "FEE_CONFIRMING";
|
||||
return response(inbound, fee, rate.getUnitPrice(), nextStatus);
|
||||
// 费用是独立状态,提前试算不得覆盖待测量或待审核等履约进度。
|
||||
return response(inbound, fee, rate.getUnitPrice(), inbound.getProcessStatus());
|
||||
}
|
||||
|
||||
/** 价格未命中时按人工单价生成正式费用,计费量与只读预览保持一致。 */
|
||||
@@ -290,9 +289,7 @@ public class FbaPricingService {
|
||||
weight, volume, quantity) != 1) throw conflict("入库单状态已变化,费用未保存");
|
||||
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
|
||||
complete(token, fee.getId(), now);
|
||||
String nextStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus())
|
||||
? inbound.getProcessStatus() : "FEE_CONFIRMING";
|
||||
return response(inbound, fee, request.unitPrice(), nextStatus);
|
||||
return response(inbound, fee, request.unitPrice(), inbound.getProcessStatus());
|
||||
}
|
||||
|
||||
/** 预览和正式计价共用价格匹配与柜级阶梯分摊,避免前端复制计价公式。 */
|
||||
@@ -577,10 +574,14 @@ public class FbaPricingService {
|
||||
public record ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus status,
|
||||
int recalculatedCount) { }
|
||||
|
||||
/** 测量审核通过后才允许确认费用,避免提前冻结费用阻断审核后的重新计价。 */
|
||||
@Transactional
|
||||
public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId,
|
||||
String idempotencyKey) {
|
||||
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
|
||||
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) {
|
||||
throw conflict("测量数据尚未审核通过,请先完成测量审核后再确认费用");
|
||||
}
|
||||
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
|
||||
throw conflict("当前入库单状态不允许确认费用");
|
||||
}
|
||||
@@ -599,10 +600,14 @@ public class FbaPricingService {
|
||||
return response(tenantId, current.getId(), null);
|
||||
}
|
||||
|
||||
/** 仅发布已通过测量审核的费用,发送不改变测量与仓库作业进度。 */
|
||||
@Transactional
|
||||
public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId,
|
||||
String idempotencyKey) {
|
||||
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
|
||||
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) {
|
||||
throw conflict("测量数据尚未审核通过,请先完成测量审核后再发送客户");
|
||||
}
|
||||
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
|
||||
throw conflict("当前入库单状态不允许发送费用");
|
||||
}
|
||||
@@ -611,8 +616,7 @@ public class FbaPricingService {
|
||||
String hash = hash("SEND", inboundId, current.getId().toString());
|
||||
InboundPricingIdempotencyEntity token = beginIdempotency(
|
||||
tenantId, operatorId, inboundId, "FEE_SEND", idempotencyKey, hash);
|
||||
String nextProcessStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus())
|
||||
? inbound.getProcessStatus() : "WAIT_PALLET";
|
||||
String nextProcessStatus = inbound.getProcessStatus();
|
||||
if ("COMPLETED".equals(token.getStatus())) {
|
||||
feeDetails.publishFreight(tenantId, inboundId, token.getResultResourceId(),
|
||||
operatorId, null, LocalDateTime.now(clock));
|
||||
@@ -812,11 +816,6 @@ public class FbaPricingService {
|
||||
return inbound;
|
||||
}
|
||||
|
||||
private static boolean preserveProcessStatusDuringPricing(String processStatus) {
|
||||
return List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")
|
||||
.contains(processStatus);
|
||||
}
|
||||
|
||||
private static boolean feeRevisionStatusSupported(String processStatus) {
|
||||
return List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
|
||||
"WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING",
|
||||
|
||||
+9
-10
@@ -169,24 +169,23 @@ public class LoadingCompletedOutboxService {
|
||||
traceId, eventKey, null, payloadNode, hash).requireValid();
|
||||
}
|
||||
|
||||
/** 国内同类照片可追加,交接按每类最新有效照片冻结一张对照图。 */
|
||||
private List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> loadingEvidence(Long containerBookingId) {
|
||||
if (containerBookingId == null || containerBookingId <= 0) {
|
||||
throw new BizException(ResultCode.CONFLICT, "装柜计划缺少柜次,不能冻结装柜照片");
|
||||
}
|
||||
List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> result = cabinetEvidenceMapper
|
||||
.byContainer(containerBookingId).stream()
|
||||
Map<String, FirstLegLoadingPlanCompletedV3.LoadingEvidence> latestByType = new java.util.TreeMap<>();
|
||||
// Mapper 按类型、证据 ID 升序返回;同类后到的有效照片覆盖前一张,原始证据全部保留。
|
||||
cabinetEvidenceMapper.byContainer(containerBookingId).stream()
|
||||
.filter(value -> Integer.valueOf(1).equals(value.getActiveFlag()) && "ACTIVE".equals(value.getStatus()))
|
||||
.map(value -> new FirstLegLoadingPlanCompletedV3.LoadingEvidence(
|
||||
value.getEvidenceType(), value.getFileId(), value.getFileName()))
|
||||
.sorted(Comparator.comparing(FirstLegLoadingPlanCompletedV3.LoadingEvidence::evidenceType))
|
||||
.toList();
|
||||
Set<String> types = result.stream().map(FirstLegLoadingPlanCompletedV3.LoadingEvidence::evidenceType)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
if (result.size() != 5 || !types.equals(Set.of(
|
||||
.forEach(value -> latestByType.put(value.getEvidenceType(),
|
||||
new FirstLegLoadingPlanCompletedV3.LoadingEvidence(
|
||||
value.getEvidenceType(), value.getFileId(), value.getFileName())));
|
||||
if (!latestByType.keySet().equals(Set.of(
|
||||
"PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL"))) {
|
||||
throw new BizException(ResultCode.CONFLICT, "国内装柜五类照片不完整,不能生成尾程交接事件");
|
||||
}
|
||||
return result;
|
||||
return List.copyOf(latestByType.values());
|
||||
}
|
||||
|
||||
private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows,
|
||||
|
||||
@@ -171,13 +171,13 @@ paths:
|
||||
/api/platform/v1/inbounds/{inboundId}/fees/confirm:
|
||||
post:
|
||||
summary: 确认最新费用快照
|
||||
description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:fba-pricing:confirm。确认费用只更新费用状态;主流程已到 WAIT_SPLIT 时保持不变。
|
||||
description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:fba-pricing:confirm。测量审核通过后才可确认费用;未通过时拒绝且不冻结费用。确认只更新费用状态,不改变履约主状态。
|
||||
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
|
||||
/api/platform/v1/inbounds/{inboundId}/fees/send:
|
||||
post:
|
||||
summary: 发送已确认费用快照
|
||||
description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:inbound:send-fee。人工发布当前已确认版本;仅此动作将费用置为 SENT、生成 PUBLISHED 的 FBA_PRICING 只读派生明细并写入 Finance Outbox。入库单已到 WAIT_SPLIT 时不得回退或阻塞主流程。
|
||||
description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:inbound:send-fee,无需额外的测量查看权限。服务端校验测量审核通过后才可发布当前已确认版本;仅此动作将费用置为 SENT、生成 PUBLISHED 的 FBA_PRICING 只读派生明细并写入 Finance Outbox,不改变履约主状态。
|
||||
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
|
||||
/api/platform/v1/inbounds/{inboundId}/fees/latest:
|
||||
|
||||
+65
-7
@@ -37,7 +37,8 @@ class FbaPricingFeeTransitionTest {
|
||||
session.getConnection();
|
||||
sql = new JdbcTemplate(ds);
|
||||
sql.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT," +
|
||||
"process_status VARCHAR(32),fee_status VARCHAR(32),deleted BOOLEAN DEFAULT FALSE)");
|
||||
"process_status VARCHAR(32),fee_status VARCHAR(32),measure_review_status VARCHAR(32) DEFAULT 'APPROVED'," +
|
||||
"chargeable_weight_kg DECIMAL(20,6),chargeable_volume_cbm DECIMAL(20,6),deleted BOOLEAN DEFAULT FALSE)");
|
||||
repository = new FbaPricingRepository();
|
||||
ReflectionTestUtils.setField(repository, "inbounds", session.getMapper(InboundPricingMapper.class));
|
||||
}
|
||||
@@ -50,7 +51,8 @@ class FbaPricingFeeTransitionTest {
|
||||
|
||||
/** 费用确认和发送不得提前完成一件代发组托或回退已有履约进度。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
|
||||
@ValueSource(strings = {"WAIT_PRICING",
|
||||
"FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
|
||||
void confirmsAndSendsWithoutAdvancingPalletization(String processStatus) {
|
||||
insertInbound(processStatus, "CALCULATED");
|
||||
|
||||
@@ -58,7 +60,7 @@ class FbaPricingFeeTransitionTest {
|
||||
assertState(processStatus, "CONFIRMED");
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isEqualTo(1);
|
||||
assertState("FEE_CONFIRMING".equals(processStatus) ? "WAIT_PALLET" : processStatus, "SENT");
|
||||
assertState(processStatus, "SENT");
|
||||
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isZero();
|
||||
}
|
||||
|
||||
@@ -71,12 +73,10 @@ class FbaPricingFeeTransitionTest {
|
||||
assertState("WAIT_PALLETIZE", feeStatus);
|
||||
}
|
||||
|
||||
/** 非确认阶段、其他客户和已删除入库单仍不可确认。 */
|
||||
/** 其他客户和已删除入库单仍不可确认。 */
|
||||
@Test
|
||||
void preservesProcessCustomerAndDeletionGuards() {
|
||||
insertInbound("WAIT_MEASURE", "CALCULATED");
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
sql.update("UPDATE fl_inbound_order SET process_status='WAIT_PALLETIZE' WHERE id=88");
|
||||
insertInbound("WAIT_PALLETIZE", "CALCULATED");
|
||||
assertThat(repository.markInboundConfirmed(20002L, 88L)).isZero();
|
||||
sql.update("UPDATE fl_inbound_order SET deleted=TRUE WHERE id=88");
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
@@ -91,6 +91,64 @@ class FbaPricingFeeTransitionTest {
|
||||
assertState("WAIT_SPLIT", "CONFIRMED");
|
||||
}
|
||||
|
||||
/** 提前试算保留测量进度,但审核前不能确认和冻结费用。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_SPLIT"})
|
||||
void calculationPreservesMeasurementWorkflow(String processStatus) {
|
||||
insertInbound(processStatus, "UNPRICED");
|
||||
boolean approved = "WAIT_SPLIT".equals(processStatus);
|
||||
sql.update("UPDATE fl_inbound_order SET measure_review_status=? WHERE id=88",
|
||||
approved ? "APPROVED" : "PENDING");
|
||||
assertThat(repository.markInboundCalculated(10001L, 88L, processStatus, "KG",
|
||||
new java.math.BigDecimal("570.5"), new java.math.BigDecimal("3.6894"),
|
||||
new java.math.BigDecimal("616.13"))).isEqualTo(1);
|
||||
assertState(processStatus, "CALCULATED");
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isEqualTo(approved ? 1 : 0);
|
||||
assertState(processStatus, approved ? "CONFIRMED" : "CALCULATED");
|
||||
}
|
||||
|
||||
/** 即使主状态已经升级,确认也须原子校验实际测量审核结果。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"})
|
||||
void rejectsConfirmationWhenMeasurementIsNotApproved(String reviewStatus) {
|
||||
insertInbound("WAIT_SPLIT", "CALCULATED");
|
||||
sql.update("UPDATE fl_inbound_order SET measure_review_status=? WHERE id=88", reviewStatus);
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
assertState("WAIT_SPLIT", "CALCULATED");
|
||||
}
|
||||
|
||||
/** 提前试算被拒绝确认后,审核通过仍可按正常费用链重新试算、确认和发送。 */
|
||||
@Test
|
||||
void pendingCalculationCanBeRepricedAndPublishedAfterMeasurementApproval() {
|
||||
insertInbound("MEASURE_PENDING", "UNPRICED");
|
||||
sql.update("UPDATE fl_inbound_order SET measure_review_status='PENDING' WHERE id=88");
|
||||
assertThat(repository.markInboundCalculated(10001L, 88L, "MEASURE_PENDING", "KG",
|
||||
new java.math.BigDecimal("570.5"), new java.math.BigDecimal("3.6894"),
|
||||
new java.math.BigDecimal("616.13"))).isEqualTo(1);
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
assertState("MEASURE_PENDING", "CALCULATED");
|
||||
|
||||
sql.update("UPDATE fl_inbound_order SET process_status='WAIT_SPLIT',measure_review_status='APPROVED' WHERE id=88");
|
||||
assertThat(repository.markInboundCalculated(10001L, 88L, "WAIT_SPLIT", "KG",
|
||||
new java.math.BigDecimal("580"), new java.math.BigDecimal("4"),
|
||||
new java.math.BigDecimal("668"))).isEqualTo(1);
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isEqualTo(1);
|
||||
assertThat(repository.markInboundFeeSent(10001L, 88L, "WAIT_SPLIT")).isEqualTo(1);
|
||||
assertState("WAIT_SPLIT", "SENT");
|
||||
assertThat(sql.queryForObject("SELECT chargeable_weight_kg FROM fl_inbound_order WHERE id=88",
|
||||
java.math.BigDecimal.class)).isEqualByComparingTo("668");
|
||||
}
|
||||
|
||||
/** 即使确认费用已完成,测量审核未通过也不能在持久化阶段发送。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"})
|
||||
void rejectsSendingUnapprovedMeasurement(String reviewStatus) {
|
||||
insertInbound("MEASURE_PENDING", "CONFIRMED");
|
||||
sql.update("UPDATE fl_inbound_order SET measure_review_status=? WHERE id=88", reviewStatus);
|
||||
assertThat(repository.markInboundFeeSent(10001L, 88L, "MEASURE_PENDING")).isZero();
|
||||
assertState("MEASURE_PENDING", "CONFIRMED");
|
||||
}
|
||||
|
||||
/** 准备单个客户的入库单,不调用共享环境。 */
|
||||
private void insertInbound(String processStatus, String feeStatus) {
|
||||
sql.update("INSERT INTO fl_inbound_order(id,tenant_id,process_status,fee_status) VALUES(88,10001,?,?)",
|
||||
|
||||
@@ -165,6 +165,7 @@ class FbaPricingServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 试算生成费用但保留原履约进度。 */
|
||||
@DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商")
|
||||
void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() {
|
||||
readyRepository();
|
||||
@@ -186,7 +187,7 @@ class FbaPricingServiceTest {
|
||||
assertThat(response.conversionFactor()).isEqualByComparingTo("167");
|
||||
assertThat(response.roundingScale()).isEqualTo(6);
|
||||
assertThat(response.roundingMode()).isEqualTo("HALF_UP");
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("FEE_CONFIRMING");
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_PRICING");
|
||||
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
|
||||
verify(repository).insertFee(fee.capture());
|
||||
assertThat(fee.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION");
|
||||
@@ -630,7 +631,7 @@ class FbaPricingServiceTest {
|
||||
|
||||
/** 待组托发送费用保留主状态,重复请求不重复生成费用事件。 */
|
||||
@ParameterizedTest
|
||||
@CsvSource({"FEE_CONFIRMING,WAIT_PALLET", "WAIT_PALLETIZE,WAIT_PALLETIZE"})
|
||||
@CsvSource({"FEE_CONFIRMING,FEE_CONFIRMING", "WAIT_PALLETIZE,WAIT_PALLETIZE"})
|
||||
@DisplayName("重复发送只返回首次结果且不会重复写Outbox")
|
||||
void sendIsIdempotentAndDoesNotDuplicateOutbox(String processStatus, String expectedStatus) {
|
||||
InboundPricingEntity inbound = inbound("10", "1");
|
||||
@@ -687,6 +688,39 @@ class FbaPricingServiceTest {
|
||||
eq(TENANT), eq(INBOUND), eq(501L), eq(1), eq(NOW));
|
||||
}
|
||||
|
||||
/** 测量未审核前拒绝单独确认,不留下阻断审核后初算的已确认费用。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"})
|
||||
void rejectsConfirmingBeforeMeasurementApproval(String reviewStatus) {
|
||||
InboundPricingEntity inbound = inbound("570.500", "3.6894");
|
||||
inbound.setProcessStatus("MEASURE_PENDING");
|
||||
inbound.setMeasureReviewStatus(reviewStatus);
|
||||
inbound.setFeeStatus("CALCULATED");
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
|
||||
assertThatThrownBy(() -> service.confirm(TENANT, OPERATOR, INBOUND, "confirm-pending"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("先完成测量审核");
|
||||
verify(repository, never()).confirmFee(anyLong(), anyLong(), anyLong(), any());
|
||||
verify(repository, never()).markInboundConfirmed(anyLong(), anyLong());
|
||||
verify(repository, never()).insertIdempotency(any());
|
||||
}
|
||||
|
||||
/** 测量未审核前拒绝发布,不能产生已发送快照或财务事件。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"})
|
||||
void rejectsSendingBeforeMeasurementApproval(String reviewStatus) {
|
||||
InboundPricingEntity inbound = inbound("570.500", "3.6894");
|
||||
inbound.setProcessStatus("MEASURE_PENDING");
|
||||
inbound.setMeasureReviewStatus(reviewStatus);
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
|
||||
assertThatThrownBy(() -> service.send(TENANT, OPERATOR, INBOUND, "send-pending"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("先完成测量审核");
|
||||
verify(repository, never()).sendFee(anyLong(), anyLong(), anyLong(), any());
|
||||
verify(repository, never()).insertIdempotency(any());
|
||||
verify(financeOutbox, never()).publishInbound(anyString(), anyLong(), anyLong(), anyLong(), anyInt(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同一幂等键用于不同试算时点会冲突")
|
||||
void rejectsReusedIdempotencyKeyWithDifferentRequest() {
|
||||
|
||||
+55
@@ -101,6 +101,61 @@ class LoadingCompletedOutboxServiceTest {
|
||||
assertThat(first.eventVersion()).isEqualTo(3);
|
||||
}
|
||||
|
||||
/** 同类追加照片不阻断预检和发布,每类冻结最新有效照片并保持重试快照稳定。 */
|
||||
@Test
|
||||
void freezesLatestActivePhotoPerTypeDuringPreflightAndPublication() throws Exception {
|
||||
Row value = row(11, 100, 101);
|
||||
value.setPlannedPackageCount(1);
|
||||
value.setPickedPackageCount(1);
|
||||
when(snapshots.previewRows(90L)).thenReturn(List.of(value));
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||
CabinetLoadingEvidenceEntity voided = evidence("PHOTO_STEP_3", 7);
|
||||
voided.setStatus("VOID");
|
||||
CabinetLoadingEvidenceEntity inactive = evidence("PHOTO_STEP_3", 8);
|
||||
inactive.setActiveFlag(null);
|
||||
// 与 Mapper 的 evidence_type、id 升序一致,后到的作废或失效照片不能替代有效照片。
|
||||
when(cabinetEvidence.byContainer(91L)).thenReturn(List.of(
|
||||
evidence("PHOTO_STEP_1", 1), evidence("PHOTO_STEP_2", 2),
|
||||
evidence("PHOTO_STEP_3", 3), evidence("PHOTO_STEP_3", 6), voided, inactive,
|
||||
evidence("PHOTO_STEP_4", 4), evidence("SEAL", 5)));
|
||||
|
||||
service.requirePublishable(90L);
|
||||
verify(outbox, org.mockito.Mockito.never()).insert(
|
||||
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
|
||||
service.publish(90L);
|
||||
service.publish(90L);
|
||||
|
||||
ArgumentCaptor<InboundOutboxEntity> captured = ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||
verify(outbox, org.mockito.Mockito.times(2)).insert(captured.capture());
|
||||
EventEnvelope first = mapper.readValue(captured.getAllValues().get(0).getPayloadJson(), EventEnvelope.class);
|
||||
EventEnvelope replay = mapper.readValue(captured.getAllValues().get(1).getPayloadJson(), EventEnvelope.class);
|
||||
assertThat(first.payload().path("loadingEvidence")).hasSize(5);
|
||||
assertThat(first.payload().at("/loadingEvidence/2/evidenceType").asText()).isEqualTo("PHOTO_STEP_3");
|
||||
assertThat(first.payload().at("/loadingEvidence/2/fileId").asLong()).isEqualTo(6L);
|
||||
assertThat(replay.payloadHash()).isEqualTo(first.payloadHash());
|
||||
}
|
||||
|
||||
/** 多张同类照片不能替代缺失类别,作废照片仍不计入五类完整性。 */
|
||||
@Test
|
||||
void rejectsMissingPhotoTypeEvenWhenMoreThanFivePhotosRemain() {
|
||||
Row value = row(11, 100, 101);
|
||||
value.setPlannedPackageCount(1);
|
||||
value.setPickedPackageCount(1);
|
||||
when(snapshots.previewRows(90L)).thenReturn(List.of(value));
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||
CabinetLoadingEvidenceEntity voided = evidence("PHOTO_STEP_4", 4);
|
||||
voided.setStatus("VOID");
|
||||
when(cabinetEvidence.byContainer(91L)).thenReturn(List.of(
|
||||
evidence("PHOTO_STEP_1", 1), evidence("PHOTO_STEP_2", 2),
|
||||
evidence("PHOTO_STEP_3", 3), evidence("PHOTO_STEP_3", 6), evidence("PHOTO_STEP_3", 7),
|
||||
voided, evidence("SEAL", 5)));
|
||||
|
||||
assertThatThrownBy(() -> service.requirePublishable(90L)).hasMessageContaining("五类照片不完整");
|
||||
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("五类照片不完整");
|
||||
verify(outbox, org.mockito.Mockito.never()).insert(
|
||||
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
|
||||
}
|
||||
|
||||
/** 非 ISO 柜号能生成当前 V3 事件,并保持冻结柜号原值。 */
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "LOCAL-2026-001"})
|
||||
|
||||
+4
-4
@@ -1307,14 +1307,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
public long pickedRemotePackages(DispatchAggregate row){return looseboardFacts==null?0:looseboardFacts.pickedRemotePackages(row);}
|
||||
/** 仓内实物作业范围,普通未收货计划仍保留并按原规则阻断。 */
|
||||
public List<DispatchItem> warehouseItems(DispatchAggregate row){return looseboardFacts==null?repository.cargo(row.getId()):looseboardFacts.warehouseItems(row.getId());}
|
||||
/** 异地取货仍占用本趟容量;使用仓内实测加散板申报,不补造实托或实测。 */
|
||||
/** 混排核对已知占用;未知重量体积不阻塞仓内出库,也不补造实测。 */
|
||||
public void requireRemoteCapacity(DispatchAggregate row,List<LoadingMeasurement> measured){
|
||||
var ids=remoteItemIds(row);if(ids.isEmpty())return;
|
||||
var remote=repository.cargo(row.getId()).stream().filter(i->ids.contains(i.getId())).toList();
|
||||
var capacity=read(row.getResourcesJson(),Resources.class).vehicle().capacity();
|
||||
if(remote.stream().anyMatch(i->i.getGrossWeightKg()==null || i.getVolumeCbm()==null) || measured.stream().anyMatch(m->m.weightKg()==null || m.volumeCbm()==null))throw conflict("混排实测与异地取货申报货量未明确");
|
||||
var weight=remote.stream().map(DispatchItem::getGrossWeightKg).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add).add(measured.stream().map(LoadingMeasurement::weightKg).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add));
|
||||
var volume=remote.stream().map(DispatchItem::getVolumeCbm).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add).add(measured.stream().map(LoadingMeasurement::volumeCbm).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add));
|
||||
// 只累加已知值作为占用下限;某项未知不能掩盖其余已知货量已经超载的事实。
|
||||
var weight=remote.stream().map(DispatchItem::getGrossWeightKg).filter(Objects::nonNull).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add).add(measured.stream().map(LoadingMeasurement::weightKg).filter(Objects::nonNull).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add));
|
||||
var volume=remote.stream().map(DispatchItem::getVolumeCbm).filter(Objects::nonNull).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add).add(measured.stream().map(LoadingMeasurement::volumeCbm).filter(Objects::nonNull).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add));
|
||||
if(remote.size()+measured.stream().mapToInt(LoadingMeasurement::palletSpaces).sum()>capacity.palletSpaces() || weight.compareTo(capacity.maxLoadKg())>0 || volume.compareTo(capacity.maxVolumeCbm())>0)throw conflict("仓内装载与异地取货合计超过本车容量");
|
||||
}
|
||||
/** 取货照片保存事务复验已确认仓内实载与当前异地申报。 */
|
||||
|
||||
+20
-7
@@ -57,7 +57,7 @@ public class DispatchLooseboardExecutionService {
|
||||
if(!(phase==Phase.PICKUP?item.canPickup():item.canArrive()))throw conflict("当前阶段不能上传该节点照片");
|
||||
return files.upload(required(number).getId(),kind(phase),actor.operatorId(),file);
|
||||
}
|
||||
/** 父锁、公共版本、不可变命令和照片推进在同一事务保存;失败不留下假发车。 */
|
||||
/** 同客户散板原单的各托照片原子保存,公共版本与命令身份仍由整车统一控制。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public Detail save(Actor actor,String number,long itemId,Phase phase,Save request){
|
||||
workflow.lockForArrangement(actor,number);var row=required(number);
|
||||
@@ -69,7 +69,9 @@ public class DispatchLooseboardExecutionService {
|
||||
}
|
||||
if(row.getRecordVersion().longValue()!=request.recordVersion())throw conflict("排车版本已变化,请刷新后重试");
|
||||
var cargo=facts.cargo(row.getId()).stream().filter(c->c.item().getId()==itemId).findFirst().orElseThrow(()->conflict("散板明细不属于本车"));
|
||||
var current=detail(row);var item=current.items().stream().filter(i->i.itemId().equals(Long.toString(itemId))).findFirst().orElseThrow();
|
||||
var members=facts.cargo(row.getId()).stream().filter(c->Objects.equals(c.item().getCustomerId(),cargo.item().getCustomerId()) && Objects.equals(c.orderNo(),cargo.orderNo())).toList();
|
||||
var memberIds=members.stream().map(c->c.item().getId().toString()).collect(java.util.stream.Collectors.toSet());
|
||||
var current=detail(row);var item=current.items().stream().filter(i->memberIds.contains(i.itemId())).findFirst().orElseThrow();
|
||||
if(!(phase==Phase.PICKUP?item.canPickup():item.canArrive()))throw conflict("当前阶段不能保存该节点照片");
|
||||
if(request.fileIds().size()>20 || new HashSet<>(request.fileIds()).size()!=request.fileIds().size())throw conflict("每个节点最多保存20张不重复照片");
|
||||
var previous=phase==Phase.PICKUP?item.pickupPhotos():item.arrivalPhotos();
|
||||
@@ -77,15 +79,18 @@ public class DispatchLooseboardExecutionService {
|
||||
if(request.fileIds().isEmpty() && ("DELIVERED".equals(row.getStage()) || phase==Phase.PICKUP && (facts.pickupConfirmed(row) || !item.arrivalPhotos().isEmpty())))throw conflict("已确认取货或送达的节点至少保留一张凭证,可先追加新照片再删除旧照片");
|
||||
var old=facts.history(row.getId());
|
||||
boolean pickupRecorded=old.stream().anyMatch(e->e.phase()==Phase.PICKUP && matches(e,cargo) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty());
|
||||
boolean arrivalRecorded=old.stream().anyMatch(e->e.phase()==Phase.ARRIVAL && matches(e,cargo) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty());
|
||||
if(old.stream().filter(e->!matches(e,cargo) || e.phase()!=phase || !Objects.equals(e.cargoHash(),row.getCargoHash()) || e.reviewRound()!=row.getReviewRound()).flatMap(e->e.files().stream()).anyMatch(f->request.fileIds().contains(f.fileId())))throw conflict("照片已绑定其他节点或明细,不能重复使用");
|
||||
boolean arrivalRecorded=members.stream().allMatch(c->old.stream().anyMatch(e->e.phase()==Phase.ARRIVAL && matches(e,c) && Objects.equals(e.cargoHash(),row.getCargoHash()) && e.reviewRound()==row.getReviewRound() && !e.files().isEmpty()));
|
||||
if(old.stream().filter(e->!memberIds.contains(e.itemId()) || !Objects.equals(e.customerId(),cargo.item().getCustomerId()) || !Objects.equals(e.orderNo(),cargo.orderNo()) || e.phase()!=phase || !Objects.equals(e.cargoHash(),row.getCargoHash()) || e.reviewRound()!=row.getReviewRound()).flatMap(e->e.files().stream()).anyMatch(f->request.fileIds().contains(f.fileId())))throw conflict("照片已绑定其他节点或明细,不能重复使用");
|
||||
var photos=request.fileIds().stream().map(id->files.metadata(row.getId(),kind(phase),id)).toList();
|
||||
if(photos.stream().anyMatch(f->!Set.of("image/jpeg","image/png").contains(f.contentType())))throw conflict("取货和送达凭证必须为照片");
|
||||
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);
|
||||
var evidence=new Evidence(Long.toString(itemId),cargo.item().getCustomerId(),cargo.orderNo(),phase,row.getCargoHash(),row.getReviewRound(),photos,at,actor.operatorId(),request.idempotencyKey(),hash);
|
||||
long original=row.getRecordVersion();row.setRecordVersion(original+1);row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));
|
||||
if(repository.saveState(row,original)!=1)throw conflict("排车版本竞争失败");
|
||||
if(repository.history(IdWorker.getId(),row,"LOOSEBOARD_PHOTO",actor.operatorId(),phase.name(),write(evidence),row.getUpdatedAt())!=1)throw conflict("散板照片保存失败");
|
||||
// 一次订单操作保留逐托来源证据,原送达量统计和结案校验继续按明细精确核对。
|
||||
for(var member:members){
|
||||
var evidence=new Evidence(member.item().getId().toString(),member.item().getCustomerId(),member.orderNo(),phase,row.getCargoHash(),row.getReviewRound(),photos,at,actor.operatorId(),request.idempotencyKey(),hash);
|
||||
if(repository.history(IdWorker.getId(),row,"LOOSEBOARD_PHOTO",actor.operatorId(),phase.name(),write(evidence),row.getUpdatedAt())!=1)throw conflict("散板照片保存失败");
|
||||
}
|
||||
if(phase==Phase.ARRIVAL && !photos.isEmpty()){
|
||||
if(!arrivalRecorded)orders.progress(cargo.item().getCustomerId(),cargo.orderNo(),actor.operatorId(),facts.deliveredPackages(cargo.item().getCustomerId(),cargo.orderNo()));
|
||||
if(current.direct() && "DELIVERING".equals(row.getStage()) && facts.complete(row,Phase.PICKUP) && facts.complete(row,Phase.ARRIVAL)){
|
||||
@@ -171,7 +176,15 @@ public class DispatchLooseboardExecutionService {
|
||||
boolean canArrive=active && (mixedPhotoStage || Set.of("DELIVERING","DELIVERED").contains(row.getStage())) && (c.item().getWarehousePalletId()!=null || confirmed && !pickup.isEmpty());
|
||||
return new Item(c.item().getId().toString(),c.item().getCustomerId(),c.orderNo(),Objects.toString(c.item().getDestinationWarehouseCode(),c.item().getDeliveryAddress()),c.item().getPackageCount(),pickup,arrival,canPickup,canArrive,pendingInbound.contains(c.item().getId())?c.orderNo()+":还没拣货入库":null,remote.contains(c.item().getId()));
|
||||
}).toList();boolean ready=active && !confirmed && (direct?"LOADING".equals(row.getStage()) && "APPROVED".equals(row.getReviewState()):!remote.isEmpty() && Set.of("AFTER_LOADING","DELIVERING").contains(row.getStage())) && facts.complete(row,Phase.PICKUP);
|
||||
return new Detail(row.getDispatchNo(),row.getRecordVersion(),row.getStage(),direct,items,ready,confirmed);
|
||||
var groups=new LinkedHashMap<List<String>,List<Item>>();
|
||||
for(var item:items)groups.computeIfAbsent(List.of(item.customerId(),item.orderNo()),key->new ArrayList<>()).add(item);
|
||||
var orders=groups.values().stream().map(group->{
|
||||
var first=group.get(0);
|
||||
return new Item(first.itemId(),first.customerId(),first.orderNo(),first.destination(),group.stream().mapToInt(Item::packages).sum(),
|
||||
group.stream().flatMap(i->i.pickupPhotos().stream()).distinct().toList(),group.stream().flatMap(i->i.arrivalPhotos().stream()).distinct().toList(),
|
||||
group.stream().allMatch(Item::canPickup),group.stream().allMatch(Item::canArrive),group.stream().map(Item::warehouseNotice).filter(Objects::nonNull).findFirst().orElse(null),group.stream().anyMatch(Item::pickupRequired));
|
||||
}).toList();
|
||||
return new Detail(row.getDispatchNo(),row.getRecordVersion(),row.getStage(),direct,orders,ready,confirmed);
|
||||
}
|
||||
/** 业务原单范围取主流程认证后的本车,不接受客户端指定归属。 */
|
||||
private DispatchAggregate required(String number){var row=repository.find(number);if(row==null)throw conflict("排车单不存在");return row;}
|
||||
|
||||
+32
-5
@@ -7,8 +7,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*;
|
||||
|
||||
/** 已发布日常派送排车复用正式加货命令,不创建第二车次或库存。 */
|
||||
@Service
|
||||
@@ -16,8 +16,10 @@ public class LooseboardCargoService {
|
||||
private final LooseboardCargoRepository cargo;
|
||||
private final DispatchAggregateRepository dispatches;
|
||||
private final DispatchAggregateService service;
|
||||
public LooseboardCargoService(LooseboardCargoRepository cargo, DispatchAggregateRepository dispatches, DispatchAggregateService service) {
|
||||
this.cargo=cargo; this.dispatches=dispatches; this.service=service;
|
||||
private final DispatchCommandHasher hashes;
|
||||
/** 加货候选复用权威来源及版本摘要,不在客户端拼造来源。 */
|
||||
public LooseboardCargoService(LooseboardCargoRepository cargo, DispatchAggregateRepository dispatches, DispatchAggregateService service,DispatchCommandHasher hashes) {
|
||||
this.cargo=cargo; this.dispatches=dispatches; this.service=service;this.hashes=hashes;
|
||||
}
|
||||
private String warehouse(String number) {
|
||||
var row=dispatches.find(number);
|
||||
@@ -25,12 +27,37 @@ public class LooseboardCargoService {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,"该排车已退出散板需求池,请刷新列表");
|
||||
return row.getOverseasWarehouseId();
|
||||
}
|
||||
/** 按完整散板订单分页;同单各托组成一次选择,不能因托盘跨页漏选。 */
|
||||
@Transactional(readOnly=true)
|
||||
public DispatchPlanningModels.CandidateList candidates(DispatchContracts.Actor actor,String number,int page,int size) {
|
||||
String warehouse=warehouse(number); var plans=cargo.readyPlans(warehouse);
|
||||
if(plans.isEmpty())return new DispatchPlanningModels.CandidateList(List.of(),Instant.now(),true,0,page,size,0);
|
||||
if(plans.size()>500)throw new ResponseStatusException(HttpStatus.CONFLICT,"同仓待排散板过多,请先在排车工作台处理");
|
||||
return service.candidates(actor,new DispatchPlanningModels.CandidateQuery(warehouse,plans,null,null,null,null,page,size,"ALL"));
|
||||
var sources=new ArrayList<Candidate>();
|
||||
for(int sourcePage=1;;sourcePage++){
|
||||
var result=service.candidates(actor,new CandidateQuery(warehouse,plans,null,null,null,null,sourcePage,50,"ALL"));
|
||||
sources.addAll(result.candidates());
|
||||
if(sourcePage>=result.pages() || result.candidates().isEmpty())break;
|
||||
}
|
||||
var groups=new LinkedHashMap<List<String>,List<Candidate>>();
|
||||
for(var source:sources)groups.computeIfAbsent(List.of(source.customerId(),Objects.requireNonNullElse(source.orderNo(),source.sourceKey())),key->new ArrayList<>()).add(source);
|
||||
var orders=groups.values().stream().map(this::orderCandidate).toList();
|
||||
long offset=(long)(page-1)*size;
|
||||
return new CandidateList(orders.stream().skip(offset).limit(size).toList(),Instant.now(),orders.size()<=size,orders.size(),page,size,(orders.size()+size-1L)/size);
|
||||
}
|
||||
/** 合并真实来源闭包和版本,显示箱数按本单来源相加,提交仍逐来源复验。 */
|
||||
private Candidate orderCandidate(List<Candidate> rows){
|
||||
var first=rows.get(0);var members=new LinkedHashMap<String,SelectionGroupSource>();
|
||||
var pallets=new LinkedHashMap<String,SelectionGroupPallet>();var reasons=new LinkedHashSet<String>();boolean complete=true;
|
||||
for(var row:rows){
|
||||
reasons.addAll(row.blockReasons());var group=row.selectionGroup();
|
||||
if(group==null){complete=false;reasons.add("订单货物来源尚未完整,请重新读取");continue;}
|
||||
complete&=group.complete();reasons.addAll(group.blockReasons());
|
||||
for(var member:group.sources())members.putIfAbsent(member.sourceKey(),member);
|
||||
for(var pallet:group.pallets())pallets.putIfAbsent(pallet.warehousePalletId()==null?group.groupKey()+":"+pallet.palletNo():pallet.warehousePalletId().toString(),pallet);
|
||||
}
|
||||
var group=new SelectionGroup("LOOSEBOARD_ORDER:"+hashes.source(List.of(first.customerId(),Objects.requireNonNullElse(first.orderNo(),first.sourceKey()))),hashes.source(rows.stream().map(Candidate::selectionGroup).toList()),List.copyOf(members.values()),List.copyOf(pallets.values()),complete,List.copyOf(reasons));
|
||||
return new Candidate(first.selection(),first.sourceKey(),first.sourceVersion(),first.customerId(),first.customerName(),first.warehouseId(),first.warehouseCode(),first.warehouseName(),first.orderNo(),first.inboundCode(),first.planNo(),null,first.containerNo(),first.destination(),rows.stream().mapToLong(Candidate::availablePackages).sum(),null,null,"ORDER_SOURCES",rows.stream().flatMap(r->r.appointmentBatches().stream()).distinct().toList(),List.copyOf(reasons),group);
|
||||
}
|
||||
@Transactional
|
||||
public DispatchContracts.CommandResult add(DispatchContracts.Actor actor,String number,DispatchCommands.AddCargo request) {
|
||||
|
||||
+28
-4
@@ -34,29 +34,32 @@ public class FleetHandoverPdaQuery {
|
||||
public record Status(String dispatchNo, String tripNo, String tripStatus, String tripRecordVersion,
|
||||
FleetTransitEvidence.Transport originalDeparture, List<Delivery> deliveries, Handover latestHandover) { }
|
||||
|
||||
/** 交接只读范围按原发车仓内实载核对,异地取货量不冒充仓库派送。 */
|
||||
public Status status(long warehouseId, String dispatchNo) {
|
||||
var binding = bindings.findByDispatchNo(dispatchNo);
|
||||
if (binding == null) throw notFound();
|
||||
var trip = trips.findByNo(binding.getTripNo());
|
||||
// Check token-bound warehouse before reading any customer's transport or historical data.
|
||||
// 先校验令牌绑定仓,再读取客户运输及历史资料。
|
||||
if (trip == null || !Objects.equals(trip.getWarehouseId(), warehouseId) || !Objects.equals(binding.getTripId(), trip.getId())) throw notFound();
|
||||
if (!List.of("IN_TRANSIT", "COMPLETED").contains(trip.getStatus()))
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "尚未发车,请按原拣货发车流程作业");
|
||||
var evidence = dispatches.transitEvidence(dispatchNo, false);
|
||||
if (!Long.toString(warehouseId).equals(evidence.warehouseId())
|
||||
|| !dispatchNo.equals(evidence.dispatchNo()) || !Objects.equals(binding.getPackageCount(), evidence.shippedPackages())) throw notFound();
|
||||
|| !dispatchNo.equals(evidence.dispatchNo()) || evidence.shippedPackages()<=0) throw notFound();
|
||||
var deliveryNumbers=new java.util.HashSet<String>();
|
||||
if(evidence.deliveries()==null || evidence.deliveries().isEmpty() || evidence.deliveries().stream().anyMatch(row ->
|
||||
row==null || row.cargo()==null || row.cargo().customerId()==null || row.cargo().customerId().isBlank()
|
||||
|| !dispatchNo.equals(row.cargo().dispatchNo()) || row.cargo().deliveryNo()==null
|
||||
|| !deliveryNumbers.add(row.cargo().deliveryNo()) || row.cargo().packages()<=0)
|
||||
|| evidence.deliveries().stream().mapToLong(row->row.cargo().packages()).sum()!=evidence.shippedPackages())throw notFound();
|
||||
)throw notFound();
|
||||
var original = history.selectList(new LambdaQueryWrapper<FleetTripHistory>().eq(FleetTripHistory::getTripId, trip.getId())
|
||||
.eq(FleetTripHistory::getAction, "DEPART"));
|
||||
if (original.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "缺少唯一原始发车记录");
|
||||
try {
|
||||
var departed = json.readValue(original.get(0).getSnapshotJson(), FleetTripModels.AuditSnapshot.class).trip();
|
||||
var originalSnapshot = json.readValue(original.get(0).getSnapshotJson(), FleetTripModels.AuditSnapshot.class);
|
||||
var departed = originalSnapshot.trip();
|
||||
if (departed == null || !trip.getTripNo().equals(departed.tripNo()) || !Long.toString(warehouseId).equals(departed.warehouseId())) throw notFound();
|
||||
requireWarehouseCargo(dispatchNo,binding,evidence,originalSnapshot);
|
||||
var originalTransport = new FleetTransitEvidence.Transport(departed.vehiclePlate(), departed.driverName(), departed.driverPhone());
|
||||
var rows = evidence.deliveries().stream().map(row -> new Delivery(row.cargo().deliveryNo(), row.status(), row.cargo().packages(),
|
||||
row.cargo().terminalAt() != null ? 0 : row.cargo().packages() - row.cargo().returnedCargo().stream().mapToInt(FleetHandoverPolicy.ReturnedCargo::packages).sum(),
|
||||
@@ -71,6 +74,27 @@ public class FleetHandoverPdaQuery {
|
||||
return new Status(dispatchNo, trip.getTripNo(), trip.getStatus(), trip.getRecordVersion().toString(), originalTransport, rows, handover);
|
||||
} catch (JsonProcessingException error) { throw new IllegalStateException("交接记录读取失败", error); }
|
||||
}
|
||||
/** 用原始逐来源实载证明客户、目的地及箱数,不能仅放宽整车数量相等判断。 */
|
||||
private static void requireWarehouseCargo(String number,FleetTripDispatch binding,FleetTransitEvidence evidence,FleetTripModels.AuditSnapshot snapshot){
|
||||
long packages=evidence.deliveries().stream().mapToLong(d->d.cargo().packages()).sum();
|
||||
if(snapshot.confirmedLoad()==null){
|
||||
if(!Objects.equals(binding.getPackageCount(),evidence.shippedPackages()) || packages!=evidence.shippedPackages())throw notFound();
|
||||
return;
|
||||
}
|
||||
var expected=new java.util.HashMap<List<String>,Long>();
|
||||
if(snapshot.confirmedLoad().cargo()==null)throw notFound();
|
||||
for(var cargo:snapshot.confirmedLoad().cargo())if(number.equals(cargo.dispatchNo())){
|
||||
if(cargo.customerId()==null || cargo.destinationKey()==null || cargo.warehousePalletId()==null || cargo.packages()<=0)throw notFound();
|
||||
expected.merge(List.of(cargo.customerId(),cargo.destinationKey()),(long)cargo.packages(),Long::sum);
|
||||
}
|
||||
var actual=new java.util.HashMap<List<String>,Long>();
|
||||
for(var delivery:evidence.deliveries()){
|
||||
var cargo=delivery.cargo();if(cargo.destinationKey()==null)throw notFound();
|
||||
actual.merge(List.of(cargo.customerId(),cargo.destinationKey()),(long)cargo.packages(),Long::sum);
|
||||
}
|
||||
if(expected.isEmpty() || !expected.equals(actual) || binding.getPackageCount()==null || packages>evidence.shippedPackages() || evidence.shippedPackages()>binding.getPackageCount())throw notFound();
|
||||
if(snapshot.dispatches()==null || snapshot.dispatches().stream().noneMatch(d->number.equals(d.dispatchNo()) && d.packageCount()==binding.getPackageCount()))throw notFound();
|
||||
}
|
||||
private static FleetTransitEvidence.Transport transport(FleetHandoverModels.Resource resource) {
|
||||
return new FleetTransitEvidence.Transport(resource.vehiclePlate(), resource.driverName(), resource.driverPhone());
|
||||
}
|
||||
|
||||
+11
-1
@@ -6,6 +6,15 @@ import java.util.List;
|
||||
/** 每趟只取首次真实发车快照;取货动作记录不能冒充仓库装车。 */
|
||||
@Mapper
|
||||
public interface TripOperationsQueryMapper {
|
||||
/** 发车边界和完成时间显式按 UTC DATETIME 读写,避免默认 Instant 处理器引入连接时区偏移。 */
|
||||
@ConstructorArgs({
|
||||
@Arg(column = "trip_no", javaType = String.class),
|
||||
@Arg(column = "status", javaType = String.class),
|
||||
@Arg(column = "completed_at", javaType = java.time.Instant.class, typeHandler = TripOperationsUtcInstantTypeHandler.class),
|
||||
@Arg(column = "snapshot", javaType = String.class),
|
||||
@Arg(column = "measured", javaType = String.class),
|
||||
@Arg(column = "transport_facts", javaType = String.class)
|
||||
})
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT t.trip_no,t.status,t.completed_at,
|
||||
@@ -27,7 +36,8 @@ public interface TripOperationsQueryMapper {
|
||||
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
|
||||
FROM ll_fleet_trip t
|
||||
WHERE t.departed_at >= #{q.from} AND t.departed_at < #{q.to}
|
||||
WHERE t.departed_at >= #{q.from,typeHandler=com.baiyue.lastleg.fleet.reporting.TripOperationsUtcInstantTypeHandler}
|
||||
AND t.departed_at < #{q.to,typeHandler=com.baiyue.lastleg.fleet.reporting.TripOperationsUtcInstantTypeHandler}
|
||||
<if test='q.warehouseId != null'>AND t.warehouse_id=#{q.warehouseId}</if>
|
||||
<if test='q.status != null and q.status != ""'>AND t.status=#{q.status}</if>
|
||||
ORDER BY t.departed_at DESC,t.id DESC LIMIT 5001
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.lastleg.fleet.reporting;
|
||||
|
||||
import org.apache.ibatis.type.BaseTypeHandler;
|
||||
import org.apache.ibatis.type.JdbcType;
|
||||
|
||||
import java.sql.CallableStatement;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
/** 出车统计的 DATETIME 保存 UTC 本地时间,读写均不使用连接或进程的默认时区。 */
|
||||
public class TripOperationsUtcInstantTypeHandler extends BaseTypeHandler<Instant> {
|
||||
/** 查询边界按 UTC 墙钟时间绑定,与发车事实的入库语义一致。 */
|
||||
@Override
|
||||
public void setNonNullParameter(PreparedStatement statement, int index, Instant value, JdbcType jdbcType)
|
||||
throws SQLException {
|
||||
statement.setObject(index, LocalDateTime.ofInstant(value, ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
/** 按列名读取 UTC 完成时间,保留尚未完成的空值。 */
|
||||
@Override
|
||||
public Instant getNullableResult(ResultSet results, String column) throws SQLException {
|
||||
return toInstant(results.getObject(column, LocalDateTime.class));
|
||||
}
|
||||
|
||||
/** 构造器按列序读取时沿用相同的 UTC 时间语义。 */
|
||||
@Override
|
||||
public Instant getNullableResult(ResultSet results, int column) throws SQLException {
|
||||
return toInstant(results.getObject(column, LocalDateTime.class));
|
||||
}
|
||||
|
||||
/** 存储过程读取同样将 DATETIME 解释为 UTC,不补默认时间。 */
|
||||
@Override
|
||||
public Instant getNullableResult(CallableStatement statement, int column) throws SQLException {
|
||||
return toInstant(statement.getObject(column, LocalDateTime.class));
|
||||
}
|
||||
|
||||
/** 将数据库墙钟值还原为 UTC 时刻。 */
|
||||
private Instant toInstant(LocalDateTime value) {
|
||||
return value == null ? null : value.toInstant(ZoneOffset.UTC);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ info:
|
||||
title: 本土散板排车照片履约
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
管理资料按同客户散板原单聚合,每单展示一块并汇总本车各托箱数。itemId为该订单在本车的代表明细,
|
||||
同单照片一次保存并在同一事务保留各托证据;不同客户或不同订单不能共用节点照片,分车仍分别履约。
|
||||
仅同客户权威关联的本土散板明细进入照片节点。全车为散板且尚无真实扫码时,审核通过后逐票保存取货照片,
|
||||
全部齐全才显示取货完成按钮;显式确认后从LOADING进入DELIVERING。不生成库存、PDA拣货或仓库出库事实。逐票保存送达照片后自动DELIVERED并结案释放车辆司机。
|
||||
混排从AFTER_LOADING或DELIVERING可逐票保存取货及送达照片;异地货同票须先取货再送达,不同票不限制取送顺序。
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"info": {
|
||||
"title": "真实出车与自有外接运营统计",
|
||||
"version": "1.0.0",
|
||||
"description": "仅实际departed_at非空车次;使用DEPART不可变快照,一车次计一次,跨客户或多目的地不重复。物理托盘去重;自有与外接箱数以BOUND外接实收归属核算,支持同排车/同托混货。全筛选汇总与分页同快照;sourceType筛选含该类货的整车,并非裁切另一类货物。from含、to不含,UTC,最多366天/5000车次。发车车牌/司机使用历史,货物结案状态使用当前事实。约定金额按币种分别累计,不是财务应收/实收;列表财务按需读取,未读取明确NOT_QUERIED;单车次财务仅按DEPART客户/排车核对唯一派送来源,Finance返回已审核有效应收与明确归属整车次账单的确认收款分配。混合来源、无收款分配或多车次归属不估算,显示待分配。应收额仍按Finance实际币种展示,不把外接约定金额当应收;成本只通过FleetTripCostService.summaries(Collection<String>)按最多100车次一批读取,保持现有trip:list成本读权限。无权限为FORBIDDEN,既有车次无登记为NOT_RECORDED;可读汇总仅包含已登记直接成本,返回未登记及未能分摊车次数,未知分摊值为null。查询不跨服务逐行HTTP,不写执行状态。 图表与分页共用全筛选快照:按UTC日展示实运托盘及外接约定金额,目的地按稳定键统计涉及车次。按托均价仅包含PALLET计价,用该币种约定总金额除确认计价标准托位,ORDER报价排除。里程mile、油耗L是授权补充事实,与车辆档案累计里程和直接成本独立。"
|
||||
"description": "仅实际departed_at非空的派送、取货及本土卡车车次;分别读取DEPART、PICKUP_DEPART/PICKUP_PICKED或LOCAL_TRUCK_DEPART不可变发车事实,取货历史须带实际发车时间,一车次计一次,跨客户或多目的地不重复。物理托盘去重;自有与外接箱数以BOUND外接实收归属核算,支持同排车/同托混货。全筛选汇总与分页同快照;sourceType筛选含该类货的整车,并非裁切另一类货物。from含、to不含,UTC,最多366天/5000车次。发车车牌/司机使用历史,货物结案状态使用当前事实。约定金额按币种分别累计,不是财务应收/实收;列表财务按需读取,未读取明确NOT_QUERIED;单车次财务仅按DEPART客户/排车核对唯一派送来源,Finance返回已审核有效应收与明确归属整车次账单的确认收款分配。混合来源、无收款分配或多车次归属不估算,显示待分配。应收额仍按Finance实际币种展示,不把外接约定金额当应收;成本只通过FleetTripCostService.summaries(Collection<String>)按最多100车次一批读取,保持现有trip:list成本读权限。无权限为FORBIDDEN,既有车次无登记为NOT_RECORDED;可读汇总仅包含已登记直接成本,返回未登记及未能分摊车次数,未知分摊值为null。查询不跨服务逐行HTTP,不写执行状态。 图表与分页共用全筛选快照:按UTC日展示实运托盘及外接约定金额,目的地按稳定键统计涉及车次。按托均价仅包含PALLET计价,用该币种约定总金额除确认计价标准托位,ORDER报价排除。里程mile、油耗L是授权补充事实,与车辆档案累计里程和直接成本独立。"
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
|
||||
@@ -14,7 +14,7 @@ paths:
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 50, default: 20 } }
|
||||
responses: { '200': { description: 统一ApiResponse;data为正式排车CandidateList,含候选来源版本、目的地及资格原因 } }
|
||||
responses: { '200': { description: 统一ApiResponse;data为CandidateList,同客户散板原单一行且按订单分页,total为订单数;availablePackages汇总本车可选的同单各托,selectionGroup包含该单所有来源及原始版本,任一来源阻断则整单不可选,加入时一次展开全部来源 } }
|
||||
post:
|
||||
summary: 将已收货散板加入需求排车
|
||||
description: 需capacity-offer:cargo-add或manage以及dispatch:edit、dispatch:list权限;复用正式AddCargo命令,校验版本、幂等、同仓及整托占用,自动刷新目的站和货量并失效审核,不执行出库拣货。
|
||||
|
||||
+20
@@ -19,6 +19,26 @@ import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
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() {
|
||||
var f=new Fixture(true);f.repository.cargo(1).get(0).setWarehousePalletId(501L);
|
||||
|
||||
+67
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -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);
|
||||
}
|
||||
}
|
||||
+18
@@ -13,6 +13,24 @@ import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
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 FleetTripMapper trips = mock(FleetTripMapper.class);
|
||||
final FleetTripHistoryMapper history = mock(FleetTripHistoryMapper.class);
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package com.baiyue.lastleg.fleet.reporting;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetTripCostService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
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.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class TripOperationsRepositoryTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private TripOperationsRepository repository;
|
||||
|
||||
/** 使用真实 Mapper 覆盖灰度历史与测试版运输查询的合并。 */
|
||||
@BeforeEach void setUp() throws Exception {
|
||||
var source = new DriverManagerDataSource(
|
||||
"jdbc:h2:mem:trip-report-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;INIT=SET TIME ZONE '+08:00'", "sa", "");
|
||||
jdbc = new JdbcTemplate(source);
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(64),trip_purpose VARCHAR(16),status VARCHAR(32),warehouse_id BIGINT,departed_at TIMESTAMP,completed_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip_history(trip_id BIGINT,record_version BIGINT,action VARCHAR(32),snapshot_json CLOB)");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip_operations_record(trip_no VARCHAR(64),record_version BIGINT,details_json CLOB)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_order(trip_id BIGINT,task_type VARCHAR(32),package_count INT)");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_pickup_task(trip_no VARCHAR(64),status VARCHAR(32),actual_packages INT)");
|
||||
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR '" + getClass().getName() + ".jsonExtract'");
|
||||
jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR '" + getClass().getName() + ".jsonUnquote'");
|
||||
var configuration = new MybatisConfiguration();
|
||||
configuration.setMapUnderscoreToCamelCase(true);
|
||||
configuration.addMapper(TripOperationsQueryMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(source);
|
||||
factory.setConfiguration(configuration);
|
||||
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(TripOperationsQueryMapper.class);
|
||||
repository = new TripOperationsRepository(mapper);
|
||||
}
|
||||
|
||||
/** 释放本例数据库,避免定向回归累积隔离状态。 */
|
||||
@AfterEach void closeDatabase() {
|
||||
if (jdbc != null) jdbc.execute("SHUTDOWN");
|
||||
}
|
||||
|
||||
/** H2 替身读取真实嵌套 JSON,缺失路径保持 SQL NULL。 */
|
||||
public static String jsonExtract(String document, String path) throws Exception {
|
||||
if (document == null) return null;
|
||||
var value = new ObjectMapper().readTree(document).at(path.substring(1).replace('.', '/'));
|
||||
return value.isMissingNode() ? null : value.toString();
|
||||
}
|
||||
|
||||
/** 保留字符串及 JSON null 的解码语义。 */
|
||||
public static String jsonUnquote(String document) throws Exception {
|
||||
return document == null ? null : new ObjectMapper().readTree(document).asText();
|
||||
}
|
||||
|
||||
/** 三类车次各取真实发车历史,取货初始空记录不能覆盖实提事实。 */
|
||||
@Test void pickupAndLocalTruckFactsUseTheirOwnHistoryWithoutBreakingDeliveryReport() throws Exception {
|
||||
trip(1, "TR-A", "DELIVERY", "IN_TRANSIT", 1, "2026-10-01 00:00:00");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,3,'DEPART',?)", TripOperationsServiceTest.DEPARTURE);
|
||||
trip(2, "PICKUP-A", "PICKUP", "COMPLETED", 1, "2026-10-02 00:00:00");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(2,1,'PICKUP_PICKED','{}'),(2,4,'PICKUP_ARRIVED','{}')");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(2,3,'PICKUP_PICKED',?)", transportSnapshot("PICKUP-A", "PICKUP"));
|
||||
jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES('PICKUP-A','PICKED',5),('PICKUP-A','ARRIVED',2),('PICKUP-A','PENDING',99)");
|
||||
trip(3, "TRUCK-A", "LOCAL_TRUCK", "IN_TRANSIT", 1, "2026-10-02 00:00:00");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(3,2,'LOCAL_TRUCK_DEPART',?)", transportSnapshot("TRUCK-A", "LOCAL_TRUCK"));
|
||||
jdbc.update("INSERT INTO ll_dispatch_order VALUES(3,'LOCAL_TRUCK',9)");
|
||||
|
||||
var result = service().report(query(null, null), false);
|
||||
|
||||
assertThat(result.page().records()).extracting(TripOperationsModels.Row::tripNo).containsExactly("TRUCK-A", "PICKUP-A", "TR-A");
|
||||
assertThat(result.summary().trips()).isEqualTo(3);
|
||||
assertThat(result.summary().physicalPallets()).isEqualTo(1);
|
||||
assertThat(result.summary().ownPackages()).isEqualTo(27);
|
||||
assertThat(result.summary().externalPackages()).isEqualTo(4);
|
||||
assertThat(result.page().records().get(0).physicalPallets()).isNull();
|
||||
assertThat(result.page().records().get(1).physicalPallets()).isNull();
|
||||
}
|
||||
|
||||
/** 非 UTC 连接仍按 UTC 含起点、不含终点筛选,完成时间也不能发生偏移。 */
|
||||
@Test void deliveryFiltersKeepInclusiveStartExclusiveEndAndWarehouseStatus() {
|
||||
trip(1, "START", "DELIVERY", "COMPLETED", 1, "2026-10-01 00:00:00");
|
||||
trip(2, "END", "DELIVERY", "COMPLETED", 1, "2026-10-03 00:00:00");
|
||||
trip(3, "BEFORE", "DELIVERY", "COMPLETED", 1, "2026-09-30 23:59:59");
|
||||
trip(4, "OTHER-WAREHOUSE", "DELIVERY", "COMPLETED", 2, "2026-10-02 00:00:00");
|
||||
trip(5, "IN-TRANSIT", "DELIVERY", "IN_TRANSIT", 1, "2026-10-02 00:00:00");
|
||||
jdbc.update("UPDATE ll_fleet_trip SET completed_at='2026-10-02 14:15:00' WHERE id=1");
|
||||
assertThat(repository.departures(query(1L, "COMPLETED")))
|
||||
.extracting(TripOperationsRepository.Departure::tripNo).containsExactly("START");
|
||||
assertThat(repository.departures(query(1L, "COMPLETED")).get(0).completedAt())
|
||||
.isEqualTo(Instant.parse("2026-10-02T14:15:00Z"));
|
||||
}
|
||||
|
||||
/** 三类车次缺少匹配历史都应显式报错,不能静默丢弃。 */
|
||||
@Test void missingMatchingHistoryStillFailsForEveryTripPurpose() {
|
||||
for (String purpose : java.util.List.of("DELIVERY", "PICKUP", "LOCAL_TRUCK")) {
|
||||
jdbc.update("DELETE FROM ll_fleet_trip_history");
|
||||
jdbc.update("DELETE FROM ll_fleet_trip");
|
||||
trip(1, "MISSING", purpose, "IN_TRANSIT", 1, "2026-10-01 00:00:00");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,1,'PICKUP_PICKED','{}')");
|
||||
assertThatThrownBy(() -> service().report(query(null, null), false))
|
||||
.isInstanceOf(IllegalStateException.class).hasMessageContaining("不可变历史").hasMessageContaining("MISSING");
|
||||
}
|
||||
}
|
||||
|
||||
/** 保留显式取货发车及旧版 DEPART 动作,未登记实提箱数不补预报。 */
|
||||
@Test void pickupDepartureAndLegacyDepartActionsRemainReadable() throws Exception {
|
||||
for (String action : java.util.List.of("PICKUP_DEPART", "DEPART")) {
|
||||
jdbc.update("DELETE FROM ll_fleet_trip_history");
|
||||
jdbc.update("DELETE FROM ll_fleet_trip");
|
||||
trip(1, "PICKUP-A", "PICKUP", "IN_TRANSIT", 1, "2026-10-01 00:00:00");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,1,?,?)", action, transportSnapshot("PICKUP-A", "PICKUP"));
|
||||
var result = service().report(query(null, null), false);
|
||||
assertThat(result.page().records()).extracting(TripOperationsModels.Row::tripNo).containsExactly("PICKUP-A");
|
||||
assertThat(result.summary().ownPackages()).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
/** 运输发车快照保留行程和实际时间,不伪造仓库装车托数。 */
|
||||
private String transportSnapshot(String number, String purpose) throws Exception {
|
||||
var json = new ObjectMapper();
|
||||
var snapshot = json.createObjectNode();
|
||||
var trip = snapshot.putObject("trip");
|
||||
trip.put("tripNo", number);
|
||||
trip.put("tripPurpose", purpose);
|
||||
trip.put("departedAt", "2026-10-01T00:00:00Z");
|
||||
var stops = trip.putArray("stops");
|
||||
stops.addObject().put("name", "起点");
|
||||
stops.addObject().put("name", "终点");
|
||||
return json.writeValueAsString(snapshot);
|
||||
}
|
||||
|
||||
/** 创建本例隔离库中的发车事实。 */
|
||||
private void trip(long id, String number, String purpose, String status, long warehouse, String departed) {
|
||||
jdbc.update("INSERT INTO ll_fleet_trip VALUES(?,?,?,?,?,?,NULL)", id, number, purpose, status, warehouse, departed);
|
||||
}
|
||||
|
||||
/** 构造范围固定的只读查询。 */
|
||||
private TripOperationsModels.Query query(Long warehouse, String status) {
|
||||
return new TripOperationsModels.Query(1, 20, Instant.parse("2026-10-01T00:00:00Z"),
|
||||
Instant.parse("2026-10-03T00:00:00Z"), warehouse, null, null, status);
|
||||
}
|
||||
|
||||
/** 关闭成本查询,聚焦发车事实的持久化和投影。 */
|
||||
private TripOperationsService service() {
|
||||
return new TripOperationsService(repository, new ObjectMapper().registerModule(new JavaTimeModule()), mock(FleetTripCostService.class));
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,8 @@ case "$command" in
|
||||
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 mockBin = join(temporaryDirectory, 'bin')
|
||||
const dockerPath = join(mockBin, 'docker')
|
||||
@@ -115,7 +116,7 @@ cp "$1" "\${2#*:}"
|
||||
service,
|
||||
envFile.replaceAll('\\', '/')
|
||||
], {
|
||||
cwd: repositoryRoot,
|
||||
cwd: missingTestLimits ? temporaryDirectory : repositoryRoot,
|
||||
encoding: 'utf8',
|
||||
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: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) => {
|
||||
@@ -232,6 +243,7 @@ test('gray deploy preserves health checks and uses an isolated Compose project',
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
assert.match(result.dockerLog, /--project-name firstleg-production/)
|
||||
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 () => {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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'
|
||||
|
||||
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'))
|
||||
.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', () => {
|
||||
for (const source of [coordinatorPipeline, pipeline]) {
|
||||
assert.match(source, /string\(name: 'IMAGE_REPOSITORY', defaultValue: params\.IMAGE_REPOSITORY \?: ''/,
|
||||
|
||||
Reference in New Issue
Block a user