fix: restore customer visit uploads and guard fee publication
This commit is contained in:
@@ -49,6 +49,7 @@ enum BusinessFileCategory {
|
|||||||
OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
OVERSEAS_CUSTOMER_VISIT(true, FormatPolicy.CUSTOMER_VISIT, Limits.CUSTOMER_VISIT, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
LOADING_LIST_CUSTOMS_RECEIPT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
LOADING_LIST_CUSTOMS_RECEIPT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
LOADING_LIST_CUSTOMS_RESULT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
LOADING_LIST_CUSTOMS_RESULT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
@@ -135,11 +136,12 @@ enum BusinessFileCategory {
|
|||||||
return Long.toString(value);
|
return Long.toString(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF, PDF_IMAGE, IMAGE }
|
enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF, PDF_IMAGE, IMAGE, CUSTOMER_VISIT }
|
||||||
private enum BusinessIdPolicy { POSITIVE_LONG, ORDER_BATCH_NO }
|
private enum BusinessIdPolicy { POSITIVE_LONG, ORDER_BATCH_NO }
|
||||||
private static final class Limits {
|
private static final class Limits {
|
||||||
private static final long STANDARD = 10L * 1024 * 1024;
|
private static final long STANDARD = 10L * 1024 * 1024;
|
||||||
private static final long CUSTOMS = 50L * 1024 * 1024;
|
private static final long CUSTOMS = 50L * 1024 * 1024;
|
||||||
private static final long ORDER_IMPORT = 5L * 1024 * 1024;
|
private static final long ORDER_IMPORT = 5L * 1024 * 1024;
|
||||||
|
private static final long CUSTOMER_VISIT = 20L * 1024 * 1024;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import java.security.NoSuchAlgorithmException;
|
|||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@@ -27,6 +28,8 @@ public class BusinessFileStorage {
|
|||||||
private static final Map<String, String> TYPES = Map.of(
|
private static final Map<String, String> TYPES = Map.of(
|
||||||
"text/plain", ".txt", "application/pdf", ".pdf",
|
"text/plain", ".txt", "application/pdf", ".pdf",
|
||||||
"image/jpeg", ".jpg", "image/png", ".png");
|
"image/jpeg", ".jpg", "image/png", ".png");
|
||||||
|
private static final Set<String> CUSTOMER_VISIT_EXTENSIONS = Set.of(
|
||||||
|
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "jpg", "jpeg", "png", "webp");
|
||||||
|
|
||||||
@Value("${baiyue.file.storage-root}")
|
@Value("${baiyue.file.storage-root}")
|
||||||
private Path configuredRoot;
|
private Path configuredRoot;
|
||||||
@@ -46,6 +49,7 @@ public class BusinessFileStorage {
|
|||||||
return store(tenantId, businessId, file, category.maxSizeBytes(), category.formatPolicy());
|
return store(tenantId, businessId, file, category.maxSizeBytes(), category.formatPolicy());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按业务类别检查大小和格式,保存真实文件并返回存储结果。 */
|
||||||
private Stored store(long tenantId, String businessId, MultipartFile file, long maxSize,
|
private Stored store(long tenantId, String businessId, MultipartFile file, long maxSize,
|
||||||
BusinessFileCategory.FormatPolicy formatPolicy) {
|
BusinessFileCategory.FormatPolicy formatPolicy) {
|
||||||
if (file == null || file.isEmpty()) throw bad("附件不能为空");
|
if (file == null || file.isEmpty()) throw bad("附件不能为空");
|
||||||
@@ -55,6 +59,8 @@ public class BusinessFileStorage {
|
|||||||
String extension;
|
String extension;
|
||||||
if (formatPolicy == BusinessFileCategory.FormatPolicy.ARBITRARY) {
|
if (formatPolicy == BusinessFileCategory.FormatPolicy.ARBITRARY) {
|
||||||
extension = "";
|
extension = "";
|
||||||
|
} else if (formatPolicy == BusinessFileCategory.FormatPolicy.CUSTOMER_VISIT) {
|
||||||
|
extension = customerVisitExtension(file);
|
||||||
} else if (formatPolicy == BusinessFileCategory.FormatPolicy.PDF) {
|
} else if (formatPolicy == BusinessFileCategory.FormatPolicy.PDF) {
|
||||||
if (!"application/pdf".equals(type)) throw bad("附件仅支持PDF");
|
if (!"application/pdf".equals(type)) throw bad("附件仅支持PDF");
|
||||||
verifyContent(file, type);
|
verifyContent(file, type);
|
||||||
@@ -85,6 +91,17 @@ public class BusinessFileStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 与回访入口保持相同扩展名约束,接受浏览器未提供 MIME 的 Office 资料。 */
|
||||||
|
private static String customerVisitExtension(MultipartFile file) {
|
||||||
|
String name = safeName(file);
|
||||||
|
int dot = name.lastIndexOf('.');
|
||||||
|
String extension = dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT);
|
||||||
|
if (!CUSTOMER_VISIT_EXTENSIONS.contains(extension)) {
|
||||||
|
throw bad("仅支持PDF、Office、JPG、PNG和WEBP文件");
|
||||||
|
}
|
||||||
|
return "." + extension;
|
||||||
|
}
|
||||||
|
|
||||||
private static void verifyXlsx(MultipartFile file) {
|
private static void verifyXlsx(MultipartFile file) {
|
||||||
try (InputStream input = file.getInputStream()) {
|
try (InputStream input = file.getInputStream()) {
|
||||||
byte[] prefix = input.readNBytes(4);
|
byte[] prefix = input.readNBytes(4);
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package com.baiyue.file.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.file.entity.FileObjectEntity;
|
||||||
|
import com.baiyue.file.mapper.FileObjectMapper;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.CsvSource;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class CustomerVisitBusinessFileTest {
|
||||||
|
private static final String TYPE = "OVERSEAS_CUSTOMER_VISIT";
|
||||||
|
private static final long MAX_SIZE = 20L * 1024 * 1024;
|
||||||
|
@TempDir Path root;
|
||||||
|
private BusinessFileStorage storage;
|
||||||
|
private FileObjectMapper files;
|
||||||
|
private InternalBusinessFileService service;
|
||||||
|
|
||||||
|
/** 初始化文件元数据映射,供真实归属查询使用。 */
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeMapping() {
|
||||||
|
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "visit-files"),
|
||||||
|
FileObjectEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 使用真实本地存储,隔离数据库写入。 */
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
storage = new BusinessFileStorage();
|
||||||
|
ReflectionTestUtils.setField(storage, "configuredRoot", root);
|
||||||
|
storage.initializeRoot();
|
||||||
|
files = mock(FileObjectMapper.class);
|
||||||
|
service = new InternalBusinessFileService();
|
||||||
|
ReflectionTestUtils.setField(service, "files", files);
|
||||||
|
ReflectionTestUtils.setField(service, "storage", storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 页面允许的全部回访资料均可保存,并保持客户及回访记录的精确归属。 */
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(delimiter = '|', value = {
|
||||||
|
"pdf|application/pdf", "doc|application/msword", "docx|application/octet-stream",
|
||||||
|
"xls|application/vnd.ms-excel", "xlsx|application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"ppt|application/vnd.ms-powerpoint", "pptx|application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
"jpg|image/jpeg", "jpeg|image/jpeg", "png|image/png", "webp|image/webp"
|
||||||
|
})
|
||||||
|
void uploadsAllSupportedFormatsWithCustomerAndVisitOwnership(String extension, String contentType) {
|
||||||
|
var file = new MockMultipartFile("file", "回访资料." + extension, contentType, new byte[]{1, 2, 3});
|
||||||
|
when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> {
|
||||||
|
FileObjectEntity row = call.getArgument(0);
|
||||||
|
row.setId(801L);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
var metadata = service.upload(1001L, TYPE, "71", 9L, file);
|
||||||
|
|
||||||
|
assertThat(metadata.ownerScope()).isEqualTo("CUSTOMER");
|
||||||
|
assertThat(metadata.tenantId()).isEqualTo(1001L);
|
||||||
|
assertThat(metadata.businessType()).isEqualTo(TYPE);
|
||||||
|
assertThat(metadata.businessId()).isEqualTo("71");
|
||||||
|
assertThat(metadata.fileId()).isEqualTo(801L);
|
||||||
|
assertThat(metadata.fileName()).isEqualTo(file.getOriginalFilename());
|
||||||
|
assertThat(metadata.contentType()).isEqualTo(contentType);
|
||||||
|
assertThat(metadata.sizeBytes()).isEqualTo(3L);
|
||||||
|
var persisted = ArgumentCaptor.forClass(FileObjectEntity.class);
|
||||||
|
verify(files).insert(persisted.capture());
|
||||||
|
assertThat(storage.read(persisted.getValue().getObjectKey())).containsExactly(1, 2, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MIME 缺失或扩展名大写不应阻断有效的 Office 回访资料。 */
|
||||||
|
@Test
|
||||||
|
void acceptsOfficeFileWithoutBrowserMimeType() {
|
||||||
|
var file = new MockMultipartFile("file", "回访记录.DOCX", null, new byte[]{1});
|
||||||
|
var stored = storage.store(1001L, "71", file, BusinessFileCategory.require(TYPE));
|
||||||
|
assertThat(stored.contentType()).isEqualTo("application/octet-stream");
|
||||||
|
assertThat(stored.objectKey()).endsWith(".docx");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 回访资料单独使用 20MiB 限制,不放宽其他业务附件的限制。 */
|
||||||
|
@Test
|
||||||
|
void enforcesNonemptyAndTwentyMibBoundary() {
|
||||||
|
var category = BusinessFileCategory.require(TYPE);
|
||||||
|
assertThatThrownBy(() -> storage.store(1001L, "71", new MockMultipartFile("file", new byte[0]), category))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("不能为空");
|
||||||
|
var oversized = mock(MultipartFile.class);
|
||||||
|
when(oversized.isEmpty()).thenReturn(false);
|
||||||
|
when(oversized.getSize()).thenReturn(MAX_SIZE + 1);
|
||||||
|
assertThatThrownBy(() -> storage.store(1001L, "71", oversized, category))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("20MB");
|
||||||
|
var boundary = new MockMultipartFile("file", "回访.doc", "application/msword", new byte[(int) MAX_SIZE]);
|
||||||
|
assertThat(storage.store(1001L, "71", boundary, category).size()).isEqualTo(MAX_SIZE);
|
||||||
|
assertThat(BusinessFileCategory.CUSTOMS_DOCUMENT.maxSizeBytes()).isEqualTo(10L * 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 不接受回访入口约定之外的文件或双扩展名可执行文件。 */
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"readme.txt", "archive.zip", "visit.pdf.exe", "visit"})
|
||||||
|
void rejectsUnsupportedFormatsBeforeMetadataIsWritten(String name) {
|
||||||
|
var file = new MockMultipartFile("file", name, "application/octet-stream", new byte[]{1});
|
||||||
|
assertThatThrownBy(() -> service.upload(1001L, TYPE, "71", 9L, file))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("仅支持");
|
||||||
|
verifyNoInteractions(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 回访资料必须归属明确客户和有效回访记录。 */
|
||||||
|
@Test
|
||||||
|
void rejectsPlatformScopeAndInvalidVisitId() {
|
||||||
|
var file = new MockMultipartFile("file", "visit.pdf", "application/pdf", new byte[]{1});
|
||||||
|
assertThatThrownBy(() -> service.upload(0L, TYPE, "71", 9L, file))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("必须归属明确客户");
|
||||||
|
assertThatThrownBy(() -> service.upload(1001L, TYPE, "0", 9L, file))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("业务ID必须有效");
|
||||||
|
verifyNoInteractions(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存回访时按客户、类型、回访记录和文件 ID 校验,不能串用其他资料。 */
|
||||||
|
@Test
|
||||||
|
void metadataRequiresEveryOwnershipKey() {
|
||||||
|
var value = new FileObjectEntity();
|
||||||
|
value.setId(801L);
|
||||||
|
value.setTenantId(1001L);
|
||||||
|
value.setBusinessType(TYPE);
|
||||||
|
value.setBusinessId("71");
|
||||||
|
when(files.selectOne(any())).thenAnswer(call -> {
|
||||||
|
LambdaQueryWrapper<FileObjectEntity> query = call.getArgument(0);
|
||||||
|
assertThat(query.getSqlSegment()).contains("tenant_id", "business_type", "business_id", "id", "status", "deleted");
|
||||||
|
return query.getParamNameValuePairs().values().containsAll(
|
||||||
|
java.util.List.of(1001L, TYPE, "71", 801L, "ACTIVE", false)) ? value : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
assertThat(service.metadata(1001L, TYPE, "71", 801L).fileId()).isEqualTo(801L);
|
||||||
|
assertThatThrownBy(() -> service.metadata(1002L, TYPE, "71", 801L))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||||
|
assertThatThrownBy(() -> service.metadata(1001L, TYPE, "72", 801L))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||||
|
assertThatThrownBy(() -> service.metadata(1001L, "OVERSEAS_EXCEPTION_EVIDENCE", "71", 801L))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||||
|
assertThatThrownBy(() -> service.metadata(1001L, TYPE, "71", 802L))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -87,11 +87,12 @@ public class PlatformFbaPricingController {
|
|||||||
return ApiResponse.success(service.confirm(scope, user.userId(), inboundId, idempotencyKey));
|
return ApiResponse.success(service.confirm(scope, user.userId(), inboundId, idempotencyKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按费用发送权限发布已确认费用,审核门禁由服务端校验且不推进履约状态。 */
|
||||||
@PostMapping("/send")
|
@PostMapping("/send")
|
||||||
@PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
|
@PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
|
||||||
+ " 'firstleg:inbound:send-fee')")
|
+ " 'firstleg:inbound:send-fee')")
|
||||||
@BusinessAudit(event = "FBA_FEE_SEND", resourceType = "INBOUND", resourceId = "#inboundId",
|
@BusinessAudit(event = "FBA_FEE_SEND", resourceType = "INBOUND", resourceId = "#inboundId",
|
||||||
description = "发送FBA费用快照并进入待托盘状态")
|
description = "发送FBA费用快照")
|
||||||
public ApiResponse<FeeSnapshotResponse> send(
|
public ApiResponse<FeeSnapshotResponse> send(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
||||||
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey) {
|
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey) {
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ public class FbaPricingRepository {
|
|||||||
return inbounds.update(null, update);
|
return inbounds.update(null, update);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 保存试算及计费量,保留测量、审核与仓库作业的主状态。 */
|
||||||
public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode,
|
public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode,
|
||||||
java.math.BigDecimal selectedWeightKg,
|
java.math.BigDecimal selectedWeightKg,
|
||||||
java.math.BigDecimal selectedVolumeCbm,
|
java.math.BigDecimal selectedVolumeCbm,
|
||||||
@@ -341,10 +342,6 @@ public class FbaPricingRepository {
|
|||||||
.eq(InboundPricingEntity::getProcessStatus, processStatus)
|
.eq(InboundPricingEntity::getProcessStatus, processStatus)
|
||||||
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED", "SENT"))
|
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED", "SENT"))
|
||||||
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
|
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
|
||||||
if (!List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")
|
|
||||||
.contains(processStatus)) {
|
|
||||||
update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING");
|
|
||||||
}
|
|
||||||
applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeMode, chargeable);
|
applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeMode, chargeable);
|
||||||
return inbounds.update(null, update);
|
return inbounds.update(null, update);
|
||||||
}
|
}
|
||||||
@@ -383,12 +380,14 @@ public class FbaPricingRepository {
|
|||||||
.set(InboundFeeEntity::getConfirmedAt, now));
|
.set(InboundFeeEntity::getConfirmedAt, now));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 确认费用时保留物流主状态,一件代发待组托也允许确认。 */
|
/** 仅确认已通过测量审核的费用,并保留当前履约状态。 */
|
||||||
public int markInboundConfirmed(long tenantId, long inboundId) {
|
public int markInboundConfirmed(long tenantId, long inboundId) {
|
||||||
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
|
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||||
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
||||||
.in(InboundPricingEntity::getProcessStatus,
|
.in(InboundPricingEntity::getProcessStatus,
|
||||||
List.of("FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"))
|
List.of("WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT",
|
||||||
|
"LOADING", "LOADED", "DELIVERED"))
|
||||||
|
.eq(InboundPricingEntity::getMeasureReviewStatus, "APPROVED")
|
||||||
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
|
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
|
||||||
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
|
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
|
||||||
}
|
}
|
||||||
@@ -436,15 +435,14 @@ public class FbaPricingRepository {
|
|||||||
.set(InboundPricingEntity::getFeeStatus, "VOIDED"));
|
.set(InboundPricingEntity::getFeeStatus, "VOIDED"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 发送仅更新费用状态,并原子校验测量已审核及履约状态未变化。 */
|
||||||
public int markInboundFeeSent(long tenantId, long inboundId, String processStatus) {
|
public int markInboundFeeSent(long tenantId, long inboundId, String processStatus) {
|
||||||
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
|
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||||
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
||||||
.eq(InboundPricingEntity::getProcessStatus, processStatus)
|
.eq(InboundPricingEntity::getProcessStatus, processStatus)
|
||||||
|
.eq(InboundPricingEntity::getMeasureReviewStatus, "APPROVED")
|
||||||
.eq(InboundPricingEntity::getFeeStatus, "CONFIRMED")
|
.eq(InboundPricingEntity::getFeeStatus, "CONFIRMED")
|
||||||
.set(InboundPricingEntity::getFeeStatus, "SENT");
|
.set(InboundPricingEntity::getFeeStatus, "SENT");
|
||||||
if ("FEE_CONFIRMING".equals(processStatus)) {
|
|
||||||
update.set(InboundPricingEntity::getProcessStatus, "WAIT_PALLET");
|
|
||||||
}
|
|
||||||
return inbounds.update(null, update);
|
return inbounds.update(null, update);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -196,9 +196,8 @@ public class FbaPricingService {
|
|||||||
}
|
}
|
||||||
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
|
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
|
||||||
complete(token, fee.getId(), now);
|
complete(token, fee.getId(), now);
|
||||||
String nextStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus())
|
// 费用是独立状态,提前试算不得覆盖待测量或待审核等履约进度。
|
||||||
? inbound.getProcessStatus() : "FEE_CONFIRMING";
|
return response(inbound, fee, rate.getUnitPrice(), inbound.getProcessStatus());
|
||||||
return response(inbound, fee, rate.getUnitPrice(), nextStatus);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 价格未命中时按人工单价生成正式费用,计费量与只读预览保持一致。 */
|
/** 价格未命中时按人工单价生成正式费用,计费量与只读预览保持一致。 */
|
||||||
@@ -290,9 +289,7 @@ public class FbaPricingService {
|
|||||||
weight, volume, quantity) != 1) throw conflict("入库单状态已变化,费用未保存");
|
weight, volume, quantity) != 1) throw conflict("入库单状态已变化,费用未保存");
|
||||||
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
|
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
|
||||||
complete(token, fee.getId(), now);
|
complete(token, fee.getId(), now);
|
||||||
String nextStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus())
|
return response(inbound, fee, request.unitPrice(), inbound.getProcessStatus());
|
||||||
? inbound.getProcessStatus() : "FEE_CONFIRMING";
|
|
||||||
return response(inbound, fee, request.unitPrice(), nextStatus);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 预览和正式计价共用价格匹配与柜级阶梯分摊,避免前端复制计价公式。 */
|
/** 预览和正式计价共用价格匹配与柜级阶梯分摊,避免前端复制计价公式。 */
|
||||||
@@ -577,10 +574,14 @@ public class FbaPricingService {
|
|||||||
public record ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus status,
|
public record ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus status,
|
||||||
int recalculatedCount) { }
|
int recalculatedCount) { }
|
||||||
|
|
||||||
|
/** 测量审核通过后才允许确认费用,避免提前冻结费用阻断审核后的重新计价。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId,
|
public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId,
|
||||||
String idempotencyKey) {
|
String idempotencyKey) {
|
||||||
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
|
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
|
||||||
|
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) {
|
||||||
|
throw conflict("测量数据尚未审核通过,请先完成测量审核后再确认费用");
|
||||||
|
}
|
||||||
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
|
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
|
||||||
throw conflict("当前入库单状态不允许确认费用");
|
throw conflict("当前入库单状态不允许确认费用");
|
||||||
}
|
}
|
||||||
@@ -599,10 +600,14 @@ public class FbaPricingService {
|
|||||||
return response(tenantId, current.getId(), null);
|
return response(tenantId, current.getId(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 仅发布已通过测量审核的费用,发送不改变测量与仓库作业进度。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId,
|
public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId,
|
||||||
String idempotencyKey) {
|
String idempotencyKey) {
|
||||||
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
|
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
|
||||||
|
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) {
|
||||||
|
throw conflict("测量数据尚未审核通过,请先完成测量审核后再发送客户");
|
||||||
|
}
|
||||||
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
|
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
|
||||||
throw conflict("当前入库单状态不允许发送费用");
|
throw conflict("当前入库单状态不允许发送费用");
|
||||||
}
|
}
|
||||||
@@ -611,8 +616,7 @@ public class FbaPricingService {
|
|||||||
String hash = hash("SEND", inboundId, current.getId().toString());
|
String hash = hash("SEND", inboundId, current.getId().toString());
|
||||||
InboundPricingIdempotencyEntity token = beginIdempotency(
|
InboundPricingIdempotencyEntity token = beginIdempotency(
|
||||||
tenantId, operatorId, inboundId, "FEE_SEND", idempotencyKey, hash);
|
tenantId, operatorId, inboundId, "FEE_SEND", idempotencyKey, hash);
|
||||||
String nextProcessStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus())
|
String nextProcessStatus = inbound.getProcessStatus();
|
||||||
? inbound.getProcessStatus() : "WAIT_PALLET";
|
|
||||||
if ("COMPLETED".equals(token.getStatus())) {
|
if ("COMPLETED".equals(token.getStatus())) {
|
||||||
feeDetails.publishFreight(tenantId, inboundId, token.getResultResourceId(),
|
feeDetails.publishFreight(tenantId, inboundId, token.getResultResourceId(),
|
||||||
operatorId, null, LocalDateTime.now(clock));
|
operatorId, null, LocalDateTime.now(clock));
|
||||||
@@ -812,11 +816,6 @@ public class FbaPricingService {
|
|||||||
return inbound;
|
return inbound;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean preserveProcessStatusDuringPricing(String processStatus) {
|
|
||||||
return List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")
|
|
||||||
.contains(processStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean feeRevisionStatusSupported(String processStatus) {
|
private static boolean feeRevisionStatusSupported(String processStatus) {
|
||||||
return List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
|
return List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
|
||||||
"WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING",
|
"WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING",
|
||||||
|
|||||||
+9
-10
@@ -169,24 +169,23 @@ public class LoadingCompletedOutboxService {
|
|||||||
traceId, eventKey, null, payloadNode, hash).requireValid();
|
traceId, eventKey, null, payloadNode, hash).requireValid();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 国内同类照片可追加,交接按每类最新有效照片冻结一张对照图。 */
|
||||||
private List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> loadingEvidence(Long containerBookingId) {
|
private List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> loadingEvidence(Long containerBookingId) {
|
||||||
if (containerBookingId == null || containerBookingId <= 0) {
|
if (containerBookingId == null || containerBookingId <= 0) {
|
||||||
throw new BizException(ResultCode.CONFLICT, "装柜计划缺少柜次,不能冻结装柜照片");
|
throw new BizException(ResultCode.CONFLICT, "装柜计划缺少柜次,不能冻结装柜照片");
|
||||||
}
|
}
|
||||||
List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> result = cabinetEvidenceMapper
|
Map<String, FirstLegLoadingPlanCompletedV3.LoadingEvidence> latestByType = new java.util.TreeMap<>();
|
||||||
.byContainer(containerBookingId).stream()
|
// Mapper 按类型、证据 ID 升序返回;同类后到的有效照片覆盖前一张,原始证据全部保留。
|
||||||
|
cabinetEvidenceMapper.byContainer(containerBookingId).stream()
|
||||||
.filter(value -> Integer.valueOf(1).equals(value.getActiveFlag()) && "ACTIVE".equals(value.getStatus()))
|
.filter(value -> Integer.valueOf(1).equals(value.getActiveFlag()) && "ACTIVE".equals(value.getStatus()))
|
||||||
.map(value -> new FirstLegLoadingPlanCompletedV3.LoadingEvidence(
|
.forEach(value -> latestByType.put(value.getEvidenceType(),
|
||||||
value.getEvidenceType(), value.getFileId(), value.getFileName()))
|
new FirstLegLoadingPlanCompletedV3.LoadingEvidence(
|
||||||
.sorted(Comparator.comparing(FirstLegLoadingPlanCompletedV3.LoadingEvidence::evidenceType))
|
value.getEvidenceType(), value.getFileId(), value.getFileName())));
|
||||||
.toList();
|
if (!latestByType.keySet().equals(Set.of(
|
||||||
Set<String> types = result.stream().map(FirstLegLoadingPlanCompletedV3.LoadingEvidence::evidenceType)
|
|
||||||
.collect(java.util.stream.Collectors.toSet());
|
|
||||||
if (result.size() != 5 || !types.equals(Set.of(
|
|
||||||
"PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL"))) {
|
"PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL"))) {
|
||||||
throw new BizException(ResultCode.CONFLICT, "国内装柜五类照片不完整,不能生成尾程交接事件");
|
throw new BizException(ResultCode.CONFLICT, "国内装柜五类照片不完整,不能生成尾程交接事件");
|
||||||
}
|
}
|
||||||
return result;
|
return List.copyOf(latestByType.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows,
|
private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows,
|
||||||
|
|||||||
@@ -171,13 +171,13 @@ paths:
|
|||||||
/api/platform/v1/inbounds/{inboundId}/fees/confirm:
|
/api/platform/v1/inbounds/{inboundId}/fees/confirm:
|
||||||
post:
|
post:
|
||||||
summary: 确认最新费用快照
|
summary: 确认最新费用快照
|
||||||
description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:fba-pricing:confirm。确认费用只更新费用状态;主流程已到 WAIT_SPLIT 时保持不变。
|
description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:fba-pricing:confirm。测量审核通过后才可确认费用;未通过时拒绝且不冻结费用。确认只更新费用状态,不改变履约主状态。
|
||||||
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||||
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
|
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
|
||||||
/api/platform/v1/inbounds/{inboundId}/fees/send:
|
/api/platform/v1/inbounds/{inboundId}/fees/send:
|
||||||
post:
|
post:
|
||||||
summary: 发送已确认费用快照
|
summary: 发送已确认费用快照
|
||||||
description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:inbound:send-fee。人工发布当前已确认版本;仅此动作将费用置为 SENT、生成 PUBLISHED 的 FBA_PRICING 只读派生明细并写入 Finance Outbox。入库单已到 WAIT_SPLIT 时不得回退或阻塞主流程。
|
description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:inbound:send-fee,无需额外的测量查看权限。服务端校验测量审核通过后才可发布当前已确认版本;仅此动作将费用置为 SENT、生成 PUBLISHED 的 FBA_PRICING 只读派生明细并写入 Finance Outbox,不改变履约主状态。
|
||||||
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||||
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
|
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
|
||||||
/api/platform/v1/inbounds/{inboundId}/fees/latest:
|
/api/platform/v1/inbounds/{inboundId}/fees/latest:
|
||||||
|
|||||||
+65
-7
@@ -37,7 +37,8 @@ class FbaPricingFeeTransitionTest {
|
|||||||
session.getConnection();
|
session.getConnection();
|
||||||
sql = new JdbcTemplate(ds);
|
sql = new JdbcTemplate(ds);
|
||||||
sql.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT," +
|
sql.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT," +
|
||||||
"process_status VARCHAR(32),fee_status VARCHAR(32),deleted BOOLEAN DEFAULT FALSE)");
|
"process_status VARCHAR(32),fee_status VARCHAR(32),measure_review_status VARCHAR(32) DEFAULT 'APPROVED'," +
|
||||||
|
"chargeable_weight_kg DECIMAL(20,6),chargeable_volume_cbm DECIMAL(20,6),deleted BOOLEAN DEFAULT FALSE)");
|
||||||
repository = new FbaPricingRepository();
|
repository = new FbaPricingRepository();
|
||||||
ReflectionTestUtils.setField(repository, "inbounds", session.getMapper(InboundPricingMapper.class));
|
ReflectionTestUtils.setField(repository, "inbounds", session.getMapper(InboundPricingMapper.class));
|
||||||
}
|
}
|
||||||
@@ -50,7 +51,8 @@ class FbaPricingFeeTransitionTest {
|
|||||||
|
|
||||||
/** 费用确认和发送不得提前完成一件代发组托或回退已有履约进度。 */
|
/** 费用确认和发送不得提前完成一件代发组托或回退已有履约进度。 */
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@ValueSource(strings = {"FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
|
@ValueSource(strings = {"WAIT_PRICING",
|
||||||
|
"FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
|
||||||
void confirmsAndSendsWithoutAdvancingPalletization(String processStatus) {
|
void confirmsAndSendsWithoutAdvancingPalletization(String processStatus) {
|
||||||
insertInbound(processStatus, "CALCULATED");
|
insertInbound(processStatus, "CALCULATED");
|
||||||
|
|
||||||
@@ -58,7 +60,7 @@ class FbaPricingFeeTransitionTest {
|
|||||||
assertState(processStatus, "CONFIRMED");
|
assertState(processStatus, "CONFIRMED");
|
||||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||||
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isEqualTo(1);
|
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isEqualTo(1);
|
||||||
assertState("FEE_CONFIRMING".equals(processStatus) ? "WAIT_PALLET" : processStatus, "SENT");
|
assertState(processStatus, "SENT");
|
||||||
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isZero();
|
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isZero();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,12 +73,10 @@ class FbaPricingFeeTransitionTest {
|
|||||||
assertState("WAIT_PALLETIZE", feeStatus);
|
assertState("WAIT_PALLETIZE", feeStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 非确认阶段、其他客户和已删除入库单仍不可确认。 */
|
/** 其他客户和已删除入库单仍不可确认。 */
|
||||||
@Test
|
@Test
|
||||||
void preservesProcessCustomerAndDeletionGuards() {
|
void preservesProcessCustomerAndDeletionGuards() {
|
||||||
insertInbound("WAIT_MEASURE", "CALCULATED");
|
insertInbound("WAIT_PALLETIZE", "CALCULATED");
|
||||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
|
||||||
sql.update("UPDATE fl_inbound_order SET process_status='WAIT_PALLETIZE' WHERE id=88");
|
|
||||||
assertThat(repository.markInboundConfirmed(20002L, 88L)).isZero();
|
assertThat(repository.markInboundConfirmed(20002L, 88L)).isZero();
|
||||||
sql.update("UPDATE fl_inbound_order SET deleted=TRUE WHERE id=88");
|
sql.update("UPDATE fl_inbound_order SET deleted=TRUE WHERE id=88");
|
||||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||||
@@ -91,6 +91,64 @@ class FbaPricingFeeTransitionTest {
|
|||||||
assertState("WAIT_SPLIT", "CONFIRMED");
|
assertState("WAIT_SPLIT", "CONFIRMED");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 提前试算保留测量进度,但审核前不能确认和冻结费用。 */
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_SPLIT"})
|
||||||
|
void calculationPreservesMeasurementWorkflow(String processStatus) {
|
||||||
|
insertInbound(processStatus, "UNPRICED");
|
||||||
|
boolean approved = "WAIT_SPLIT".equals(processStatus);
|
||||||
|
sql.update("UPDATE fl_inbound_order SET measure_review_status=? WHERE id=88",
|
||||||
|
approved ? "APPROVED" : "PENDING");
|
||||||
|
assertThat(repository.markInboundCalculated(10001L, 88L, processStatus, "KG",
|
||||||
|
new java.math.BigDecimal("570.5"), new java.math.BigDecimal("3.6894"),
|
||||||
|
new java.math.BigDecimal("616.13"))).isEqualTo(1);
|
||||||
|
assertState(processStatus, "CALCULATED");
|
||||||
|
assertThat(repository.markInboundConfirmed(10001L, 88L)).isEqualTo(approved ? 1 : 0);
|
||||||
|
assertState(processStatus, approved ? "CONFIRMED" : "CALCULATED");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 即使主状态已经升级,确认也须原子校验实际测量审核结果。 */
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"})
|
||||||
|
void rejectsConfirmationWhenMeasurementIsNotApproved(String reviewStatus) {
|
||||||
|
insertInbound("WAIT_SPLIT", "CALCULATED");
|
||||||
|
sql.update("UPDATE fl_inbound_order SET measure_review_status=? WHERE id=88", reviewStatus);
|
||||||
|
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||||
|
assertState("WAIT_SPLIT", "CALCULATED");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提前试算被拒绝确认后,审核通过仍可按正常费用链重新试算、确认和发送。 */
|
||||||
|
@Test
|
||||||
|
void pendingCalculationCanBeRepricedAndPublishedAfterMeasurementApproval() {
|
||||||
|
insertInbound("MEASURE_PENDING", "UNPRICED");
|
||||||
|
sql.update("UPDATE fl_inbound_order SET measure_review_status='PENDING' WHERE id=88");
|
||||||
|
assertThat(repository.markInboundCalculated(10001L, 88L, "MEASURE_PENDING", "KG",
|
||||||
|
new java.math.BigDecimal("570.5"), new java.math.BigDecimal("3.6894"),
|
||||||
|
new java.math.BigDecimal("616.13"))).isEqualTo(1);
|
||||||
|
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||||
|
assertState("MEASURE_PENDING", "CALCULATED");
|
||||||
|
|
||||||
|
sql.update("UPDATE fl_inbound_order SET process_status='WAIT_SPLIT',measure_review_status='APPROVED' WHERE id=88");
|
||||||
|
assertThat(repository.markInboundCalculated(10001L, 88L, "WAIT_SPLIT", "KG",
|
||||||
|
new java.math.BigDecimal("580"), new java.math.BigDecimal("4"),
|
||||||
|
new java.math.BigDecimal("668"))).isEqualTo(1);
|
||||||
|
assertThat(repository.markInboundConfirmed(10001L, 88L)).isEqualTo(1);
|
||||||
|
assertThat(repository.markInboundFeeSent(10001L, 88L, "WAIT_SPLIT")).isEqualTo(1);
|
||||||
|
assertState("WAIT_SPLIT", "SENT");
|
||||||
|
assertThat(sql.queryForObject("SELECT chargeable_weight_kg FROM fl_inbound_order WHERE id=88",
|
||||||
|
java.math.BigDecimal.class)).isEqualByComparingTo("668");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 即使确认费用已完成,测量审核未通过也不能在持久化阶段发送。 */
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"})
|
||||||
|
void rejectsSendingUnapprovedMeasurement(String reviewStatus) {
|
||||||
|
insertInbound("MEASURE_PENDING", "CONFIRMED");
|
||||||
|
sql.update("UPDATE fl_inbound_order SET measure_review_status=? WHERE id=88", reviewStatus);
|
||||||
|
assertThat(repository.markInboundFeeSent(10001L, 88L, "MEASURE_PENDING")).isZero();
|
||||||
|
assertState("MEASURE_PENDING", "CONFIRMED");
|
||||||
|
}
|
||||||
|
|
||||||
/** 准备单个客户的入库单,不调用共享环境。 */
|
/** 准备单个客户的入库单,不调用共享环境。 */
|
||||||
private void insertInbound(String processStatus, String feeStatus) {
|
private void insertInbound(String processStatus, String feeStatus) {
|
||||||
sql.update("INSERT INTO fl_inbound_order(id,tenant_id,process_status,fee_status) VALUES(88,10001,?,?)",
|
sql.update("INSERT INTO fl_inbound_order(id,tenant_id,process_status,fee_status) VALUES(88,10001,?,?)",
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ class FbaPricingServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
/** 试算生成费用但保留原履约进度。 */
|
||||||
@DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商")
|
@DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商")
|
||||||
void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() {
|
void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() {
|
||||||
readyRepository();
|
readyRepository();
|
||||||
@@ -186,7 +187,7 @@ class FbaPricingServiceTest {
|
|||||||
assertThat(response.conversionFactor()).isEqualByComparingTo("167");
|
assertThat(response.conversionFactor()).isEqualByComparingTo("167");
|
||||||
assertThat(response.roundingScale()).isEqualTo(6);
|
assertThat(response.roundingScale()).isEqualTo(6);
|
||||||
assertThat(response.roundingMode()).isEqualTo("HALF_UP");
|
assertThat(response.roundingMode()).isEqualTo("HALF_UP");
|
||||||
assertThat(response.nextProcessStatus()).isEqualTo("FEE_CONFIRMING");
|
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_PRICING");
|
||||||
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
|
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
|
||||||
verify(repository).insertFee(fee.capture());
|
verify(repository).insertFee(fee.capture());
|
||||||
assertThat(fee.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION");
|
assertThat(fee.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION");
|
||||||
@@ -630,7 +631,7 @@ class FbaPricingServiceTest {
|
|||||||
|
|
||||||
/** 待组托发送费用保留主状态,重复请求不重复生成费用事件。 */
|
/** 待组托发送费用保留主状态,重复请求不重复生成费用事件。 */
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@CsvSource({"FEE_CONFIRMING,WAIT_PALLET", "WAIT_PALLETIZE,WAIT_PALLETIZE"})
|
@CsvSource({"FEE_CONFIRMING,FEE_CONFIRMING", "WAIT_PALLETIZE,WAIT_PALLETIZE"})
|
||||||
@DisplayName("重复发送只返回首次结果且不会重复写Outbox")
|
@DisplayName("重复发送只返回首次结果且不会重复写Outbox")
|
||||||
void sendIsIdempotentAndDoesNotDuplicateOutbox(String processStatus, String expectedStatus) {
|
void sendIsIdempotentAndDoesNotDuplicateOutbox(String processStatus, String expectedStatus) {
|
||||||
InboundPricingEntity inbound = inbound("10", "1");
|
InboundPricingEntity inbound = inbound("10", "1");
|
||||||
@@ -687,6 +688,39 @@ class FbaPricingServiceTest {
|
|||||||
eq(TENANT), eq(INBOUND), eq(501L), eq(1), eq(NOW));
|
eq(TENANT), eq(INBOUND), eq(501L), eq(1), eq(NOW));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 测量未审核前拒绝单独确认,不留下阻断审核后初算的已确认费用。 */
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"})
|
||||||
|
void rejectsConfirmingBeforeMeasurementApproval(String reviewStatus) {
|
||||||
|
InboundPricingEntity inbound = inbound("570.500", "3.6894");
|
||||||
|
inbound.setProcessStatus("MEASURE_PENDING");
|
||||||
|
inbound.setMeasureReviewStatus(reviewStatus);
|
||||||
|
inbound.setFeeStatus("CALCULATED");
|
||||||
|
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.confirm(TENANT, OPERATOR, INBOUND, "confirm-pending"))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("先完成测量审核");
|
||||||
|
verify(repository, never()).confirmFee(anyLong(), anyLong(), anyLong(), any());
|
||||||
|
verify(repository, never()).markInboundConfirmed(anyLong(), anyLong());
|
||||||
|
verify(repository, never()).insertIdempotency(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 测量未审核前拒绝发布,不能产生已发送快照或财务事件。 */
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"})
|
||||||
|
void rejectsSendingBeforeMeasurementApproval(String reviewStatus) {
|
||||||
|
InboundPricingEntity inbound = inbound("570.500", "3.6894");
|
||||||
|
inbound.setProcessStatus("MEASURE_PENDING");
|
||||||
|
inbound.setMeasureReviewStatus(reviewStatus);
|
||||||
|
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.send(TENANT, OPERATOR, INBOUND, "send-pending"))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("先完成测量审核");
|
||||||
|
verify(repository, never()).sendFee(anyLong(), anyLong(), anyLong(), any());
|
||||||
|
verify(repository, never()).insertIdempotency(any());
|
||||||
|
verify(financeOutbox, never()).publishInbound(anyString(), anyLong(), anyLong(), anyLong(), anyInt(), any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("同一幂等键用于不同试算时点会冲突")
|
@DisplayName("同一幂等键用于不同试算时点会冲突")
|
||||||
void rejectsReusedIdempotencyKeyWithDifferentRequest() {
|
void rejectsReusedIdempotencyKeyWithDifferentRequest() {
|
||||||
|
|||||||
+55
@@ -101,6 +101,61 @@ class LoadingCompletedOutboxServiceTest {
|
|||||||
assertThat(first.eventVersion()).isEqualTo(3);
|
assertThat(first.eventVersion()).isEqualTo(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 同类追加照片不阻断预检和发布,每类冻结最新有效照片并保持重试快照稳定。 */
|
||||||
|
@Test
|
||||||
|
void freezesLatestActivePhotoPerTypeDuringPreflightAndPublication() throws Exception {
|
||||||
|
Row value = row(11, 100, 101);
|
||||||
|
value.setPlannedPackageCount(1);
|
||||||
|
value.setPickedPackageCount(1);
|
||||||
|
when(snapshots.previewRows(90L)).thenReturn(List.of(value));
|
||||||
|
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||||
|
CabinetLoadingEvidenceEntity voided = evidence("PHOTO_STEP_3", 7);
|
||||||
|
voided.setStatus("VOID");
|
||||||
|
CabinetLoadingEvidenceEntity inactive = evidence("PHOTO_STEP_3", 8);
|
||||||
|
inactive.setActiveFlag(null);
|
||||||
|
// 与 Mapper 的 evidence_type、id 升序一致,后到的作废或失效照片不能替代有效照片。
|
||||||
|
when(cabinetEvidence.byContainer(91L)).thenReturn(List.of(
|
||||||
|
evidence("PHOTO_STEP_1", 1), evidence("PHOTO_STEP_2", 2),
|
||||||
|
evidence("PHOTO_STEP_3", 3), evidence("PHOTO_STEP_3", 6), voided, inactive,
|
||||||
|
evidence("PHOTO_STEP_4", 4), evidence("SEAL", 5)));
|
||||||
|
|
||||||
|
service.requirePublishable(90L);
|
||||||
|
verify(outbox, org.mockito.Mockito.never()).insert(
|
||||||
|
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
|
||||||
|
service.publish(90L);
|
||||||
|
service.publish(90L);
|
||||||
|
|
||||||
|
ArgumentCaptor<InboundOutboxEntity> captured = ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||||
|
verify(outbox, org.mockito.Mockito.times(2)).insert(captured.capture());
|
||||||
|
EventEnvelope first = mapper.readValue(captured.getAllValues().get(0).getPayloadJson(), EventEnvelope.class);
|
||||||
|
EventEnvelope replay = mapper.readValue(captured.getAllValues().get(1).getPayloadJson(), EventEnvelope.class);
|
||||||
|
assertThat(first.payload().path("loadingEvidence")).hasSize(5);
|
||||||
|
assertThat(first.payload().at("/loadingEvidence/2/evidenceType").asText()).isEqualTo("PHOTO_STEP_3");
|
||||||
|
assertThat(first.payload().at("/loadingEvidence/2/fileId").asLong()).isEqualTo(6L);
|
||||||
|
assertThat(replay.payloadHash()).isEqualTo(first.payloadHash());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 多张同类照片不能替代缺失类别,作废照片仍不计入五类完整性。 */
|
||||||
|
@Test
|
||||||
|
void rejectsMissingPhotoTypeEvenWhenMoreThanFivePhotosRemain() {
|
||||||
|
Row value = row(11, 100, 101);
|
||||||
|
value.setPlannedPackageCount(1);
|
||||||
|
value.setPickedPackageCount(1);
|
||||||
|
when(snapshots.previewRows(90L)).thenReturn(List.of(value));
|
||||||
|
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||||
|
CabinetLoadingEvidenceEntity voided = evidence("PHOTO_STEP_4", 4);
|
||||||
|
voided.setStatus("VOID");
|
||||||
|
when(cabinetEvidence.byContainer(91L)).thenReturn(List.of(
|
||||||
|
evidence("PHOTO_STEP_1", 1), evidence("PHOTO_STEP_2", 2),
|
||||||
|
evidence("PHOTO_STEP_3", 3), evidence("PHOTO_STEP_3", 6), evidence("PHOTO_STEP_3", 7),
|
||||||
|
voided, evidence("SEAL", 5)));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.requirePublishable(90L)).hasMessageContaining("五类照片不完整");
|
||||||
|
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("五类照片不完整");
|
||||||
|
verify(outbox, org.mockito.Mockito.never()).insert(
|
||||||
|
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
/** 非 ISO 柜号能生成当前 V3 事件,并保持冻结柜号原值。 */
|
/** 非 ISO 柜号能生成当前 V3 事件,并保持冻结柜号原值。 */
|
||||||
@org.junit.jupiter.params.ParameterizedTest
|
@org.junit.jupiter.params.ParameterizedTest
|
||||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "LOCAL-2026-001"})
|
@org.junit.jupiter.params.provider.ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "LOCAL-2026-001"})
|
||||||
|
|||||||
Reference in New Issue
Block a user