Compare commits
10
Commits
5a8918e2e4
...
test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e99658bfa | ||
|
|
44202a7bff | ||
|
|
ef2ac1eafd | ||
|
|
08ea663334 | ||
|
|
8809801070 | ||
|
|
497544e9fd | ||
|
|
83c3b02d96 | ||
|
|
d1ade7e9dd | ||
|
|
97c80574df | ||
|
|
b063d7866c |
@@ -10,6 +10,9 @@ baiyue.file.storage-root: ${FILE_STORAGE_ROOT}
|
||||
baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:}
|
||||
baiyue:
|
||||
file:
|
||||
pda:
|
||||
# 在目标 Nacos 命名空间填写公网 HTTPS 根地址,不含 /api;真实环境地址不提交仓库。
|
||||
public-base-url: ""
|
||||
export:
|
||||
definitions:
|
||||
CUSTOMER_LIST:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 单测试环境数据库空库初始化
|
||||
|
||||
`migrations/overseas/baiyue_lastleg/V38__allow_multi_order_delivery_returns.sql` 移除派送退回的旧全历史唯一索引,保留普通来源索引,支持同派送一次选择多张原订单各建异常。配套 Lastleg 批次行锁、总量校验及 Admin 多选;可重复执行,不改业务记录。已在本机应用并核对行数,远程 TEST 尚未执行。
|
||||
|
||||
`20261104-unify-utf8mb4-0900-ai-ci.sql` 是本机 MySQL 8.4.11 五个业务库的排序规则汇总脚本,覆盖 `baiyue_event_bridge`、`baiyue_event_bridge_overseas`、`baiyue_firstleg`、`baiyue_lastleg`、`baiyue_order`。脚本已在本机执行并核验 209 张表、1,644 个文本字段;其他环境执行前必须核对实际字段定义和唯一索引冲突,不能默认部署。脚本只收敛旧的 Unicode 不区分大小写排序规则,保留有意使用的 `utf8mb4_bin`。
|
||||
|
||||
退货仓租改为原托整托计费、余一箱仍计一托,现有阶梯不变。已有 Lastleg 库先执行 [整托计费结构增量](return-storage-rent-whole-pallet.sql) 增加 `ll_storage_rent_lot.charged_days`,再发布匹配的 Lastleg;源基线和海外空库基线已同步。实际出库事务推进已生成费用的截止库龄日,同托同计租日不重复收费。切换期间暂停相关出库、取消未消费预占,并逐单核对旧比例费用;脚本不修改既有费用、库存或资金。本次仅准备代码及脚本,未执行环境 SQL 或部署。
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
-- 同一派送可按多张原订单生成退回异常;活动批次由派送行锁和来源数量校验保护。
|
||||
-- 只调整索引,不修改异常、签收、库存或资金事实;重复执行跳过已完成项。
|
||||
USE baiyue_lastleg;
|
||||
|
||||
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_exception_order' AND index_name = 'idx_ll_exception_delivery_return'),
|
||||
'ALTER TABLE ll_exception_order ADD KEY idx_ll_exception_delivery_return (customer_id, delivery_order_id, exception_type)', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_exception_order' AND index_name = 'uk_ll_exception_delivery_return'),
|
||||
'ALTER TABLE ll_exception_order DROP INDEX uk_ll_exception_delivery_return', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
@@ -49,6 +49,7 @@ enum BusinessFileCategory {
|
||||
OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_RELABEL_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) {
|
||||
|
||||
@@ -100,8 +100,8 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
WHERE departure.container_booking_id=b.id
|
||||
AND departure.confirmed_at IS NOT NULL AND departure.reversed_at IS NULL
|
||||
AND departure.status='WAITING_FOR_LOADING')
|
||||
AND ((#{query.scope}='ACTIVE' AND b.actual_empty_reported_at IS NULL)
|
||||
OR (#{query.scope}='HISTORY' AND b.actual_empty_reported_at IS NOT NULL))))
|
||||
AND ((#{query.scope}='ACTIVE' AND NOT EXISTS (SELECT 1 FROM fl_terminal_drayage returned WHERE returned.container_booking_id=b.id AND returned.actual_empty_return_at IS NOT NULL UNION ALL SELECT 1 FROM fl_carrier_schedule_node_fact returned_node WHERE returned_node.container_booking_id=b.id AND returned_node.standard_node='EMPTY_RETURNED' AND returned_node.actual_at IS NOT NULL))
|
||||
OR (#{query.scope}='HISTORY' AND EXISTS (SELECT 1 FROM fl_terminal_drayage returned WHERE returned.container_booking_id=b.id AND returned.actual_empty_return_at IS NOT NULL UNION ALL SELECT 1 FROM fl_carrier_schedule_node_fact returned_node WHERE returned_node.container_booking_id=b.id AND returned_node.standard_node='EMPTY_RETURNED' AND returned_node.actual_at IS NOT NULL)))))
|
||||
AND (#{query.keyword} IS NULL OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
|
||||
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%')
|
||||
OR b.bill_of_lading_no LIKE CONCAT('%', #{query.keyword}, '%')
|
||||
|
||||
@@ -379,6 +379,26 @@ public interface DropshipPalletizationMapper {
|
||||
ORDER BY inbound_detail_id
|
||||
""") List<PalletLine> sealedPalletLines(@Param("palletId") long palletId);
|
||||
|
||||
/** 未装托盘标签按物理箱身份转移,保留原箱码不重新编号。 */
|
||||
@Update("""
|
||||
<script>UPDATE fl_inbound_carton_label SET inbound_id=#{child},order_id=#{order},inbound_code=#{code},
|
||||
total_carton_count=#{packages},detail_package_count=LEAST(detail_package_count,#{packages})
|
||||
WHERE tenant_id=#{tenant} AND inbound_id=#{source} AND batch_status='ACTIVE'
|
||||
AND id IN (SELECT c.carton_label_id FROM fl_dropship_pallet_carton c
|
||||
WHERE c.tenant_id=#{tenant} AND c.inbound_id=#{source} AND c.pallet_id IS NOT NULL
|
||||
AND c.pallet_id NOT IN <foreach collection="picked" item="id" open="(" separator="," close=")">#{id}</foreach>)
|
||||
</script>""")
|
||||
int transferRemainderLabels(@Param("tenant") long tenant,@Param("source") long source,@Param("child") long child,
|
||||
@Param("order") long order,@Param("code") String code,@Param("packages") int packages,@Param("picked") List<Long> picked);
|
||||
/** 实箱随余量单转移,已装托与跨客户托内其他成员保持原归属。 */
|
||||
@Update("""
|
||||
<script>UPDATE fl_dropship_pallet_carton SET inbound_id=#{child}
|
||||
WHERE tenant_id=#{tenant} AND inbound_id=#{source} AND pallet_id IS NOT NULL
|
||||
AND pallet_id NOT IN <foreach collection="picked" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</script>""")
|
||||
int transferRemainderCartons(@Param("tenant") long tenant,@Param("source") long source,
|
||||
@Param("child") long child,@Param("picked") List<Long> picked);
|
||||
|
||||
@Getter @Setter class TaskRow { private Long id,tenantId,inboundId,warehouseId,assigneeId; private String inboundCode,customerName,warehouseName,taskStatus,assigneeName,processStatus,fulfillmentType; private Integer totalCartons,countedCartons,allocatedCartons,discrepancyCartons,expectedPalletCount,version; }
|
||||
@Getter @Setter class PalletRow { private Long id,taskId,tenantId,inboundId,warehouseId; private String palletNo,palletStatus,sealOperationId; private Integer version; private LocalDateTime completedAt; }
|
||||
@Getter @Setter class PlanPalletMemberRow { private Long palletId,accountId,inboundId,orderId; private String orderNo,inboundCode,customerName,orderStatusSnapshot; private Integer availablePackageCount; private LocalDateTime expectedArrivalAt; }
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
/** 分托装柜余量的实箱归属迁移。 */
|
||||
@Repository
|
||||
public class DropshipSplitRepository {
|
||||
private final DropshipPalletizationMapper mapper;
|
||||
/** 复用一件代发实箱账本,不另建库存。 */
|
||||
public DropshipSplitRepository(DropshipPalletizationMapper mapper) { this.mapper = mapper; }
|
||||
/** 排除本柜已拣托盘,逐箱迁移到余量入仓单;数量不一致回滚本地结算。 */
|
||||
public void transferRemainder(long tenant, long source, long child, long order, String code,
|
||||
int packages, List<Long> pickedPallets) {
|
||||
if (pickedPallets.isEmpty()) throw new BizException(ResultCode.CONFLICT, "缺少已装整托事实");
|
||||
if (mapper.transferRemainderLabels(tenant, source, child, order, code, packages, pickedPallets) != packages
|
||||
|| mapper.transferRemainderCartons(tenant, source, child, pickedPallets) != packages)
|
||||
throw new BizException(ResultCode.CONFLICT, "未装托盘箱数与余量不一致,请刷新后重试");
|
||||
}
|
||||
}
|
||||
@@ -331,6 +331,7 @@ public class FbaPricingRepository {
|
||||
return inbounds.update(null, update);
|
||||
}
|
||||
|
||||
/** 保存试算及计费量,保留测量、审核与仓库作业的主状态。 */
|
||||
public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode,
|
||||
java.math.BigDecimal selectedWeightKg,
|
||||
java.math.BigDecimal selectedVolumeCbm,
|
||||
@@ -341,10 +342,6 @@ public class FbaPricingRepository {
|
||||
.eq(InboundPricingEntity::getProcessStatus, processStatus)
|
||||
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED", "SENT"))
|
||||
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
|
||||
if (!List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")
|
||||
.contains(processStatus)) {
|
||||
update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING");
|
||||
}
|
||||
applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeMode, chargeable);
|
||||
return inbounds.update(null, update);
|
||||
}
|
||||
@@ -383,12 +380,14 @@ public class FbaPricingRepository {
|
||||
.set(InboundFeeEntity::getConfirmedAt, now));
|
||||
}
|
||||
|
||||
/** 确认费用时保留物流主状态,一件代发待组托也允许确认。 */
|
||||
/** 仅确认已通过测量审核的费用,并保留当前履约状态。 */
|
||||
public int markInboundConfirmed(long tenantId, long inboundId) {
|
||||
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
||||
.in(InboundPricingEntity::getProcessStatus,
|
||||
List.of("FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"))
|
||||
List.of("WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT",
|
||||
"LOADING", "LOADED", "DELIVERED"))
|
||||
.eq(InboundPricingEntity::getMeasureReviewStatus, "APPROVED")
|
||||
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
|
||||
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
|
||||
}
|
||||
@@ -436,15 +435,14 @@ public class FbaPricingRepository {
|
||||
.set(InboundPricingEntity::getFeeStatus, "VOIDED"));
|
||||
}
|
||||
|
||||
/** 发送仅更新费用状态,并原子校验测量已审核及履约状态未变化。 */
|
||||
public int markInboundFeeSent(long tenantId, long inboundId, String processStatus) {
|
||||
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
||||
.eq(InboundPricingEntity::getProcessStatus, processStatus)
|
||||
.eq(InboundPricingEntity::getMeasureReviewStatus, "APPROVED")
|
||||
.eq(InboundPricingEntity::getFeeStatus, "CONFIRMED")
|
||||
.set(InboundPricingEntity::getFeeStatus, "SENT");
|
||||
if ("FEE_CONFIRMING".equals(processStatus)) {
|
||||
update.set(InboundPricingEntity::getProcessStatus, "WAIT_PALLET");
|
||||
}
|
||||
return inbounds.update(null, update);
|
||||
}
|
||||
|
||||
|
||||
@@ -196,9 +196,8 @@ public class FbaPricingService {
|
||||
}
|
||||
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
|
||||
complete(token, fee.getId(), now);
|
||||
String nextStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus())
|
||||
? inbound.getProcessStatus() : "FEE_CONFIRMING";
|
||||
return response(inbound, fee, rate.getUnitPrice(), nextStatus);
|
||||
// 费用是独立状态,提前试算不得覆盖待测量或待审核等履约进度。
|
||||
return response(inbound, fee, rate.getUnitPrice(), inbound.getProcessStatus());
|
||||
}
|
||||
|
||||
/** 价格未命中时按人工单价生成正式费用,计费量与只读预览保持一致。 */
|
||||
@@ -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,
|
||||
|
||||
@@ -53,6 +53,8 @@ public class PdaPickSettlementService {
|
||||
private InboundHoldGuard inboundHoldGuard;
|
||||
@Resource
|
||||
private DropshipPalletizationMapper dropshipPallets;
|
||||
@Resource
|
||||
private com.baiyue.firstleg.repository.DropshipSplitRepository dropshipSplits;
|
||||
@Value("${baiyue.security.internal-service-token}")
|
||||
private String internalToken;
|
||||
|
||||
@@ -121,6 +123,7 @@ public class PdaPickSettlementService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 按柜次实拣数量结算,一件代发保留未装整托供后续柜次使用。 */
|
||||
private boolean settleInbound(long parentInboundId, int pickedPackageCount,
|
||||
List<LoadingPickItemEntity> taskItems, long taskId,
|
||||
long operatorId, String operatorName) {
|
||||
@@ -184,9 +187,7 @@ public class PdaPickSettlementService {
|
||||
&& pickedByDetail.values().stream().mapToInt(Integer::intValue).sum() != pickedPackageCount) {
|
||||
throw conflict("PDA 已拣明细与入库单已拣箱数不一致");
|
||||
}
|
||||
if (dropshipWholePallet && childPackageCount != 0) {
|
||||
throw conflict("一件代发现场托盘必须整票装柜,不能生成余量分片");
|
||||
}
|
||||
|
||||
List<MeasurementSplitLine> measurementLines = dropshipWholePallet
|
||||
? wholePalletMeasurementLines(measurements, pickedPackageCount, parentAvailable)
|
||||
: measurementLines(measurements, pickedByDetail);
|
||||
@@ -219,8 +220,17 @@ public class PdaPickSettlementService {
|
||||
requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), parentInboundId, rootInboundId, remainderChildId,
|
||||
orderSplit.remainderChild().orderId(), orderSplit.remainderChild().orderNo(),
|
||||
remainderChildCode, childPackageCount, sequence, "REMAINDER", operatorId, operatorName), "余量子入库单");
|
||||
splits.transferRemainingCartonLabels(parent.tenantId(), parentInboundId, remainderChildId,
|
||||
orderSplit.remainderChild().orderId(), remainderChildCode, pickedPackageCount, childPackageCount);
|
||||
if (dropshipWholePallet) {
|
||||
var pickedPalletIds = taskItems.stream().filter(item -> "PICKED".equals(item.getItemStatus())
|
||||
&& "SEALED_PALLET".equals(item.getUnitType())).map(LoadingPickItemEntity::getPalletId)
|
||||
.filter(Objects::nonNull).distinct().toList();
|
||||
// 托内箱按实际箱唛移动,不能按箱序号截取,否则会移错已装托盘。
|
||||
dropshipSplits.transferRemainder(parent.tenantId(), parentInboundId, remainderChildId,
|
||||
orderSplit.remainderChild().orderId(), remainderChildCode, childPackageCount, pickedPalletIds);
|
||||
} else {
|
||||
splits.transferRemainingCartonLabels(parent.tenantId(), parentInboundId, remainderChildId,
|
||||
orderSplit.remainderChild().orderId(), remainderChildCode, pickedPackageCount, childPackageCount);
|
||||
}
|
||||
splits.normalizeLoadedCartonLabels(parent.tenantId(), parentInboundId, pickedPackageCount);
|
||||
}
|
||||
requireAffected(splits.insertBatchWithChildren(batchId, parent.tenantId(), rootInboundId, operationId, parentInboundId,
|
||||
@@ -408,17 +418,24 @@ public class PdaPickSettlementService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 一件代发逐箱封托已经冻结 SKU 归属;测量批次只承担整票体积重量,不再冒充 SKU 明细。 */
|
||||
/** 整托箱数守恒,测量行按箱数分摊重量体积;真实SKU组成仍由封托箱账本冻结。 */
|
||||
private static List<MeasurementSplitLine> wholePalletMeasurementLines(List<MeasurementItemRow> measurements,
|
||||
int pickedPackageCount,
|
||||
int parentAvailable) {
|
||||
int measuredPackages = measurements.stream()
|
||||
.mapToInt(item -> count(item.packageCount(), "测量明细箱数")).sum();
|
||||
if (pickedPackageCount != parentAvailable || measuredPackages != parentAvailable) {
|
||||
if (pickedPackageCount <= 0 || pickedPackageCount > parentAvailable || measuredPackages != parentAvailable) {
|
||||
throw conflict("一件代发整托箱数与入库单、测量批次不守恒");
|
||||
}
|
||||
return measurements.stream().map(item -> new MeasurementSplitLine(item,
|
||||
count(item.packageCount(), "测量明细箱数"), 0)).toList();
|
||||
int left = pickedPackageCount;
|
||||
List<MeasurementSplitLine> result = new ArrayList<>();
|
||||
for (var item : measurements) {
|
||||
int total = count(item.packageCount(), "测量明细箱数");
|
||||
int picked = Math.min(total, left);
|
||||
result.add(new MeasurementSplitLine(item, picked, total - picked));
|
||||
left -= picked;
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static Map<Long, Integer> measurementQuantities(List<MeasurementSplitLine> lines) {
|
||||
|
||||
@@ -72,7 +72,7 @@ public class SplitPreloadService {
|
||||
@Transactional
|
||||
public void ensurePendingForLoadingAllocation(long tenantId, long inboundId, long operatorId,
|
||||
String operatorName) {
|
||||
ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, false);
|
||||
ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName);
|
||||
}
|
||||
|
||||
/** Creates a provisional preload lifecycle for an order selected before it reaches WAIT_SPLIT. */
|
||||
@@ -113,16 +113,17 @@ public class SplitPreloadService {
|
||||
@Transactional
|
||||
public void ensureForRunningLoadingAllocation(long tenantId, long inboundId, long operatorId,
|
||||
String operatorName) {
|
||||
ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, true);
|
||||
ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName);
|
||||
}
|
||||
|
||||
/** 复用既有预装计划;同一入仓单其他未分配整托可继续进入不同柜次。 */
|
||||
private void ensureLoadingAllocationLifecycle(long tenantId, long inboundId, long operatorId,
|
||||
String operatorName, boolean running) {
|
||||
String operatorName) {
|
||||
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
|
||||
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
|
||||
boolean waitingSplit = isWaitingSplit(inbound.processStatus());
|
||||
boolean loading = "LOADING".equals(inbound.processStatus());
|
||||
if (!waitingSplit && !(running && loading)) {
|
||||
if (!waitingSplit && !loading) {
|
||||
throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以建立智能装柜预装计划");
|
||||
}
|
||||
if (loading && plan == null) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
+23
@@ -28,6 +28,29 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@DisplayName("货柜数据库映射契约")
|
||||
class ContainerBookingMapperContractTest {
|
||||
/** 卸货不等于还柜;人工还柜及船司实际还柜都进入历史页签,两个集合互斥。 */
|
||||
@Test void completedScopeUsesActualEmptyReturnInsteadOfUnloadingReport() throws Exception {
|
||||
String page = String.join("\n", ContainerBookingMapper.class
|
||||
.getDeclaredMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class,
|
||||
com.baiyue.firstleg.dto.ContainerDtos.Query.class, String.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
int start = page.indexOf("SELECT 1 FROM fl_terminal_drayage returned");
|
||||
String returned = page.substring(start, page.indexOf("))", start));
|
||||
var data = new JdbcDataSource();
|
||||
data.setURL("jdbc:h2:mem:return-scope-" + java.util.UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
var db = new JdbcTemplate(data);
|
||||
db.execute("CREATE TABLE fl_container_booking(id BIGINT,actual_empty_reported_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE fl_carrier_schedule_node_fact(container_booking_id BIGINT,standard_node VARCHAR(40),actual_at TIMESTAMP)");
|
||||
db.execute("INSERT INTO fl_container_booking VALUES(1,CURRENT_TIMESTAMP),(2,NULL),(3,NULL),(4,NULL)");
|
||||
db.execute("INSERT INTO fl_terminal_drayage VALUES(2,CURRENT_TIMESTAMP)");
|
||||
db.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(3,'EMPTY_RETURNED',CURRENT_TIMESTAMP),(4,'EMPTY_RETURNED',NULL)");
|
||||
assertThat(db.queryForList("SELECT b.id FROM fl_container_booking b WHERE EXISTS ("+returned+") ORDER BY b.id",Long.class))
|
||||
.containsExactly(2L,3L);
|
||||
assertThat(db.queryForList("SELECT b.id FROM fl_container_booking b WHERE NOT EXISTS ("+returned+") ORDER BY b.id",Long.class))
|
||||
.containsExactly(1L,4L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("货柜映射平台级最终表且状态只从装柜计划读取")
|
||||
void mapsFinalBookingContract() throws Exception {
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** 分托结算直接验证实际Mapper,箱序号不代表物理已装托盘。 */
|
||||
class DropshipSplitRepositoryTest {
|
||||
/** 已装箱位于序号末尾时,仍只迁移另一托的箱,跨客户同码不受影响。 */
|
||||
@Test void transfersOnlyUnloadedPhysicalCartons() throws Exception {
|
||||
var data = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
var db = new JdbcTemplate(data);
|
||||
db.execute("CREATE TABLE fl_inbound_carton_label(id BIGINT,tenant_id BIGINT,inbound_id BIGINT,order_id BIGINT,inbound_code VARCHAR(50),batch_status VARCHAR(20),total_carton_count INT,detail_package_count INT,box_code VARCHAR(50))");
|
||||
db.execute("CREATE TABLE fl_dropship_pallet_carton(id BIGINT,tenant_id BIGINT,inbound_id BIGINT,pallet_id BIGINT,carton_label_id BIGINT)");
|
||||
db.update("INSERT INTO fl_inbound_carton_label VALUES(1,10,31,100,'IN-1','ACTIVE',4,4,'BOX-1'),(2,10,31,100,'IN-1','ACTIVE',4,4,'BOX-2'),(3,10,31,100,'IN-1','ACTIVE',4,4,'BOX-3'),(4,10,31,100,'IN-1','ACTIVE',4,4,'BOX-4'),(5,20,31,200,'IN-2','ACTIVE',1,1,'BOX-1')");
|
||||
db.update("INSERT INTO fl_dropship_pallet_carton VALUES(11,10,31,802,1),(12,10,31,802,2),(13,10,31,801,3),(14,10,31,801,4),(15,20,31,802,5)");
|
||||
var config = new MybatisConfiguration(); config.addMapper(DropshipPalletizationMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config);
|
||||
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DropshipPalletizationMapper.class);
|
||||
new DropshipSplitRepository(mapper).transferRemainder(10,31,32,101,"IN-1-1",2,List.of(801L));
|
||||
assertThat(db.queryForList("SELECT id FROM fl_inbound_carton_label WHERE inbound_id=32 ORDER BY id",Long.class)).containsExactly(1L,2L);
|
||||
assertThat(db.queryForList("SELECT id FROM fl_dropship_pallet_carton WHERE inbound_id=32 ORDER BY id",Long.class)).containsExactly(11L,12L);
|
||||
assertThat(db.queryForObject("SELECT inbound_id FROM fl_inbound_carton_label WHERE id=5",Long.class)).isEqualTo(31L);
|
||||
assertThat(db.queryForList("SELECT box_code FROM fl_inbound_carton_label WHERE inbound_id=32 ORDER BY id",String.class)).containsExactly("BOX-1","BOX-2");
|
||||
// 下一柜重新读取封托内容时,货物身份必须归到余量子入库单和余量子订单。
|
||||
db.execute("CREATE TABLE fl_dropship_pallet(id BIGINT,pallet_no VARCHAR(50),pallet_status VARCHAR(20))");
|
||||
db.execute("INSERT INTO fl_dropship_pallet VALUES(802,'DP-802','SEALED')");
|
||||
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD inbound_detail_id BIGINT DEFAULT 41");
|
||||
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD sku_code VARCHAR(30) DEFAULT 'SKU-1'");
|
||||
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD cargo_name VARCHAR(30) DEFAULT '货物'");
|
||||
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD actual_sku_quantity INT DEFAULT 1");
|
||||
db.execute("CREATE TABLE fl_dropship_pallet_carton_sku(carton_id BIGINT,inbound_detail_id BIGINT,sku_code VARCHAR(30),cargo_name VARCHAR(30),actual_quantity INT)");
|
||||
db.execute("CREATE TABLE fl_inbound_order(id BIGINT,tenant_id BIGINT,order_id BIGINT,order_no VARCHAR(50),inbound_code VARCHAR(50),warehouse_id BIGINT,channel_code VARCHAR(30),channel_name VARCHAR(30),destination_warehouse_id BIGINT,destination_code VARCHAR(30),destination_name VARCHAR(30),customer_name VARCHAR(30),actual_gross_weight_kg DECIMAL,actual_net_weight_kg DECIMAL,actual_volume_cbm DECIMAL,total_package_count INT)");
|
||||
db.execute("INSERT INTO fl_inbound_order(id,tenant_id,order_id,order_no,inbound_code,total_package_count) VALUES(32,10,101,'ORDER-1-1','IN-1-1',2)");
|
||||
var remainder = mapper.sealedPalletLines(802);
|
||||
assertThat(remainder).hasSize(1);
|
||||
assertThat(remainder.get(0).getInboundId()).isEqualTo(32L);
|
||||
assertThat(remainder.get(0).getOrderId()).isEqualTo(101L);
|
||||
assertThat(remainder.get(0).getInboundDetailId()).isEqualTo(41L);
|
||||
assertThat(remainder.get(0).getCartonCount()).isEqualTo(2);
|
||||
|
||||
}
|
||||
}
|
||||
+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"})
|
||||
|
||||
@@ -46,6 +46,7 @@ class PdaPickSettlementServiceTest {
|
||||
@MockBean private OrderFulfillmentSplitClient orderSplits;
|
||||
@MockBean private InboundHoldGuard inboundHoldGuard;
|
||||
@MockBean private DropshipPalletizationMapper dropshipPallets;
|
||||
@MockBean private com.baiyue.firstleg.repository.DropshipSplitRepository dropshipSplits;
|
||||
@Resource private PdaPickSettlementService service;
|
||||
|
||||
@Test
|
||||
@@ -266,6 +267,29 @@ class PdaPickSettlementServiceTest {
|
||||
&& request.lines().size() == 1 && request.lines().get(0).packageCount() == 20));
|
||||
}
|
||||
|
||||
@Test
|
||||
void settlesOneSealedPalletAndMovesOnlyUnloadedPalletsToRemainder() {
|
||||
var picked = item(11L, 21L, 31L, 41L, 2, 2, "PICKED");
|
||||
picked.setUnitType("SEALED_PALLET"); picked.setPalletId(801L); picked.setPalletNo("DP-1");
|
||||
resolved(List.of(picked));
|
||||
var physical = new DropshipPalletizationMapper.PlanPalletMemberRow();
|
||||
physical.setPalletId(801L); physical.setInboundId(31L); physical.setAccountId(10001L);
|
||||
physical.setAvailablePackageCount(2);
|
||||
when(dropshipPallets.palletInboundMembersByPalletIds(any())).thenReturn(List.of(physical));
|
||||
when(splits.lockParent(31L)).thenReturn(parent(4));
|
||||
when(splits.fulfillmentType(10001L, 31L)).thenReturn("ONE_CLICK_DROPSHIP");
|
||||
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
|
||||
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 4)));
|
||||
when(allocations.lockConfirmed(10001L, 31L)).thenReturn(List.of(allocation(1L, 1, 501L, 4)));
|
||||
writes(2, 2);
|
||||
service.settle(1L, 9L, "operator");
|
||||
verify(dropshipSplits).transferRemainder(eq(10001L), eq(31L), anyLong(), eq(1002L),
|
||||
eq("IN-001-1"), eq(2), eq(List.of(801L)));
|
||||
verify(splits, never()).transferRemainingCartonLabels(anyLong(), anyLong(), anyLong(), anyLong(), anyString(), anyInt(), anyInt());
|
||||
verify(splits).updateMeasurementItemForLoaded(10001L, 51L, 2);
|
||||
verify(splits).markSourceLoaded(10001L, 31L, 2, 9L, 3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlyPureSkippedInboundRestoresCandidate() {
|
||||
LoadingPickItemEntity item = item(12L, 22L, 32L, 42L, 8, 0, "SKIPPED");
|
||||
|
||||
+2
-3
@@ -148,14 +148,13 @@ class SplitPreloadServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinaryLoadingAllocationStillRequiresWaitingSplit() {
|
||||
void otherCabinetMayReuseExistingPreloadWhileSourceIsLoading() {
|
||||
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3));
|
||||
PreloadPlanEntity plan = new PreloadPlanEntity();
|
||||
plan.setStatus("PENDING");
|
||||
when(repository.findPlan(10, 20)).thenReturn(plan);
|
||||
|
||||
assertThatThrownBy(() -> service.ensurePendingForLoadingAllocation(10, 20, 7, "admin"))
|
||||
.hasMessageContaining("仅待分柜订单");
|
||||
service.ensurePendingForLoadingAllocation(10, 20, 7, "admin");
|
||||
|
||||
verify(repository, never()).createPlan(any());
|
||||
}
|
||||
|
||||
+2
-2
@@ -11,8 +11,8 @@ public interface DispatchWholePdaReadMapper {
|
||||
record Picked(Long itemId,long packages) { }
|
||||
record Invalid(Long dispatchId,long quantity) { }
|
||||
record Carton(Long itemId,Long cartonId,String cartonMark,String status,long productQuantity) { }
|
||||
/** 精确单号或有界阶段列表均先在SQL限制绑定仓。 */
|
||||
@Select("<script>SELECT * FROM ll_dispatch_order WHERE overseas_warehouse_id=#{warehouse} AND status='ACTIVE' "
|
||||
/** 绑定仓仅下发仓内作业;本土卡车与到仓取货排车继续在后台执行。 */
|
||||
@Select("<script>SELECT * FROM ll_dispatch_order WHERE overseas_warehouse_id=#{warehouse} AND status='ACTIVE' AND COALESCE(task_type,'DELIVERY') NOT IN ('LOCAL_TRUCK','PICKUP') "
|
||||
+"<choose><when test='number != null'>AND dispatch_no=#{number} </when><when test='stage != null'>AND stage=#{stage} </when>"
|
||||
+"<otherwise>AND (stage IN('LOADING','AFTER_LOADING','DELIVERING') OR (stage='BEFORE_LOADING' AND review_state='LOADING_PENDING')) </otherwise></choose>ORDER BY updated_at DESC,id DESC LIMIT 201</script>")
|
||||
List<DispatchAggregate> headers(@Param("warehouse")String warehouse,@Param("number")String number,@Param("stage")String stage);
|
||||
|
||||
@@ -54,10 +54,11 @@ public final class DispatchViews {
|
||||
List<DispatchDeliveryModels.DeliveryReference> deliveries,
|
||||
Instant createdAt, Instant updatedAt, String remark) { }
|
||||
|
||||
/** 散板按客户和商业订单汇总;来源键集合仅供只读明细关联,不替代作业来源身份。 */
|
||||
public record OrderDetail(String customerId, String customerName, String orderNo, String inboundCode,
|
||||
BigDecimal grossWeightKg, Integer palletCount, String destinationFbaWarehouse,
|
||||
BigDecimal volumeCbm, String containerNo, String sourceKey,
|
||||
String sourceBusinessType, String sourceBusinessNo) { }
|
||||
String sourceBusinessType, String sourceBusinessNo, List<String> sourceKeys) { }
|
||||
|
||||
/** 页头元数据和四个内容对象固定,附加能力不扩展为新页签。 */
|
||||
public record Detail(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion,
|
||||
|
||||
+2
-1
@@ -6,11 +6,12 @@ import java.util.List;
|
||||
|
||||
/** 客户照片仅查询已核准客户与原单的有效承运关联。 */
|
||||
@Repository
|
||||
public class MerchantTransportPhotoRepository {
|
||||
public class MerchantTransportPhotoRepository implements com.baiyue.lastleg.looseboard.port.LooseboardDispatches {
|
||||
private final MerchantTransportPhotoMapper mapper;
|
||||
/** 注入本域查询入口。 */
|
||||
public MerchantTransportPhotoRepository(MerchantTransportPhotoMapper mapper){this.mapper=mapper;}
|
||||
/** 返回原散板单实际关联的排车身份。 */
|
||||
@Override
|
||||
public List<String> looseboardDispatches(String customer,String number){return mapper.looseboardDispatches(customer,number);}
|
||||
/** 返回原卡车单有效运输任务。 */
|
||||
public List<String> truckDispatches(String customer,String number){return mapper.truckDispatches(customer,number);}
|
||||
|
||||
+45
-11
@@ -962,8 +962,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
return batches.stream().collect(Collectors.toMap(
|
||||
b -> List.of(b.batchKey(),b.customerId(),b.destinationKey()),b->b,(a,b)->a,LinkedHashMap::new)).values().stream().map(b -> {
|
||||
var confirmed=confirmedAppointments.stream().filter(w->w.batchKey().equals(b.batchKey()) && w.customerId().equals(b.customerId()) && w.destinationKey().equals(b.destinationKey())).findFirst().orElse(null);
|
||||
return new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),confirmed==null?null:confirmed.confirmationNo(),b.destinationKey(),b.warehouseCode(),
|
||||
confirmed==null?"PENDING":"SUCCESS",confirmed==null?null:confirmed.startAt(),confirmed==null?"尚无当前有效收货确认":null,b.customerId(),List.of());
|
||||
return DispatchDestinationAppointments.booked(b,confirmed);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@@ -1230,9 +1229,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
reviews(row),read(row.getResourcesJson(),Resources.class),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),instant(row.getCreatedAt()),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),counts(row),reconciled(row),row.getArrangementKey()!=null,actions(actor,row),reviewAppointments(row));
|
||||
}
|
||||
|
||||
/** 混托多订单无法真实分摊重量体积时保持未知,不能给每票复制整托总量。 */
|
||||
/** 散板同客户同商业订单只展示一行,逐托来源仍保留用于原作业和运营明细关联。 */
|
||||
private List<DispatchViews.OrderDetail> orderDetails(List<DispatchItem> cargo) {
|
||||
var result=new ArrayList<DispatchViews.OrderDetail>();
|
||||
var groups=new LinkedHashMap<List<String>,List<OrderDisplayContribution>>();
|
||||
var businessSources=looseboardDisplay.sources(cargo.stream().map(i->new com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.Reference(i.getCustomerId(),i.getOrderNo(),i.getPlanPalletId())).toList());
|
||||
for(var item:cargo) {
|
||||
var source=read(item.getSourceSnapshotJson(),SourceDisplay.class);
|
||||
@@ -1240,11 +1239,46 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
if(orders.isEmpty()) orders=Collections.singletonList(null);
|
||||
for(var order:orders) {
|
||||
var business=looseboardDisplay.find(businessSources,item.getCustomerId(),order,item.getPlanPalletId());
|
||||
result.add(new DispatchViews.OrderDetail(item.getCustomerId(),item.getCustomerName(),business==null?order:business.orderNo(),business==null?item.getInboundCode():null,orders.size()==1?item.getGrossWeightKg():null,
|
||||
orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,business==null?source.containerNo():null,item.getSourceKey(),business==null?item.getSourceBusinessType():"LOCAL_LOOSEBOARD",business==null?item.getSourceBusinessNo():business.orderNo()));
|
||||
var detail=new DispatchViews.OrderDetail(item.getCustomerId(),item.getCustomerName(),business==null?order:business.orderNo(),business==null?item.getInboundCode():null,orders.size()==1?item.getGrossWeightKg():null,
|
||||
orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,business==null?source.containerNo():null,item.getSourceKey(),business==null?item.getSourceBusinessType():"LOCAL_LOOSEBOARD",business==null?item.getSourceBusinessNo():business.orderNo(),item.getSourceKey()==null?List.of():List.of(item.getSourceKey()));
|
||||
// 商业订单必须来自权威关联,不能按LB前缀或跨客户同号合并。
|
||||
var key=business==null?List.of("SOURCE",Integer.toString(groups.size())):List.of("LOOSEBOARD",item.getCustomerId(),business.orderNo());
|
||||
String pallet=item.getWarehousePalletId()!=null?"WAREHOUSE:"+item.getWarehousePalletId():item.getPlanPalletId()!=null?"PLAN:"+item.getPlanPalletId():"SOURCE:"+Objects.toString(item.getSourceKey(),Objects.toString(item.getId(),Integer.toString(cargo.indexOf(item))));
|
||||
var members=groups.computeIfAbsent(key,ignored->new ArrayList<>());
|
||||
if(members.stream().noneMatch(member->member.item()==item))members.add(new OrderDisplayContribution(item,detail,pallet));
|
||||
}
|
||||
}
|
||||
return List.copyOf(result);
|
||||
var palletOrders=new HashMap<String,Set<List<String>>>();
|
||||
groups.forEach((key,members)->members.forEach(member->palletOrders.computeIfAbsent(member.palletKey(),ignored->new HashSet<>()).add(key)));
|
||||
var sharedPallets=palletOrders.entrySet().stream().filter(entry->entry.getValue().size()>1).map(Map.Entry::getKey).collect(Collectors.toSet());
|
||||
return groups.entrySet().stream().map(entry->"LOOSEBOARD".equals(entry.getKey().get(0))?looseboardOrderDetail(entry.getValue(),sharedPallets):entry.getValue().get(0).detail()).toList();
|
||||
}
|
||||
|
||||
/** 仅在展示层保存原来源与实托身份,不改写排车货物或库存。 */
|
||||
private record OrderDisplayContribution(DispatchItem item,DispatchViews.OrderDetail detail,String palletKey) { }
|
||||
|
||||
/** 同托只计一次;混托无法分摊、缺测量或同托测量冲突均保持未知。 */
|
||||
private DispatchViews.OrderDetail looseboardOrderDetail(List<OrderDisplayContribution> members,Set<String> sharedPallets) {
|
||||
var first=members.get(0).detail();
|
||||
var pallets=members.stream().collect(Collectors.groupingBy(OrderDisplayContribution::palletKey,LinkedHashMap::new,Collectors.toList()));
|
||||
Integer palletCount=members.stream().anyMatch(m->m.detail().palletCount()==null)?null:pallets.size();
|
||||
var destinations=members.stream().map(m->m.detail().destinationFbaWarehouse()).filter(Objects::nonNull).filter(s->!s.isBlank()).distinct().toList();
|
||||
return new DispatchViews.OrderDetail(first.customerId(),first.customerName(),first.orderNo(),null,
|
||||
orderMeasurement(pallets.values(),sharedPallets,DispatchViews.OrderDetail::grossWeightKg),palletCount,destinations.isEmpty()?null:String.join("、",destinations),
|
||||
orderMeasurement(pallets.values(),sharedPallets,DispatchViews.OrderDetail::volumeCbm),null,first.sourceKey(),first.sourceBusinessType(),first.sourceBusinessNo(),
|
||||
members.stream().flatMap(m->m.detail().sourceKeys().stream()).distinct().toList());
|
||||
}
|
||||
|
||||
/** 每个实托必须有一致测量才能汇总,不能把未知量当零或重复累加整托量。 */
|
||||
private java.math.BigDecimal orderMeasurement(Collection<List<OrderDisplayContribution>> pallets,Set<String> sharedPallets,java.util.function.Function<DispatchViews.OrderDetail,java.math.BigDecimal> measurement) {
|
||||
var total=java.math.BigDecimal.ZERO;
|
||||
for(var members:pallets) {
|
||||
if(sharedPallets.contains(members.get(0).palletKey()))return null;
|
||||
var value=measurement.apply(members.get(0).detail());
|
||||
if(value==null || members.stream().anyMatch(m->measurement.apply(m.detail())==null || value.compareTo(measurement.apply(m.detail()))!=0))return null;
|
||||
total=total.add(value);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** 合并Fleet权威资源、本趟照片和维护资料,不写入车辆或司机主档。 */
|
||||
@@ -1308,14 +1342,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
public long pickedRemotePackages(DispatchAggregate row){return looseboardFacts==null?0:looseboardFacts.pickedRemotePackages(row);}
|
||||
/** 仓内实物作业范围,普通未收货计划仍保留并按原规则阻断。 */
|
||||
public List<DispatchItem> warehouseItems(DispatchAggregate row){return looseboardFacts==null?repository.cargo(row.getId()):looseboardFacts.warehouseItems(row.getId());}
|
||||
/** 异地取货仍占用本趟容量;使用仓内实测加散板申报,不补造实托或实测。 */
|
||||
/** 混排核对已知占用;未知重量体积不阻塞仓内出库,也不补造实测。 */
|
||||
public void requireRemoteCapacity(DispatchAggregate row,List<LoadingMeasurement> measured){
|
||||
var ids=remoteItemIds(row);if(ids.isEmpty())return;
|
||||
var remote=repository.cargo(row.getId()).stream().filter(i->ids.contains(i.getId())).toList();
|
||||
var capacity=read(row.getResourcesJson(),Resources.class).vehicle().capacity();
|
||||
if(remote.stream().anyMatch(i->i.getGrossWeightKg()==null || i.getVolumeCbm()==null) || measured.stream().anyMatch(m->m.weightKg()==null || m.volumeCbm()==null))throw conflict("混排实测与异地取货申报货量未明确");
|
||||
var weight=remote.stream().map(DispatchItem::getGrossWeightKg).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add).add(measured.stream().map(LoadingMeasurement::weightKg).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add));
|
||||
var volume=remote.stream().map(DispatchItem::getVolumeCbm).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add).add(measured.stream().map(LoadingMeasurement::volumeCbm).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add));
|
||||
// 只累加已知值作为占用下限;某项未知不能掩盖其余已知货量已经超载的事实。
|
||||
var weight=remote.stream().map(DispatchItem::getGrossWeightKg).filter(Objects::nonNull).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add).add(measured.stream().map(LoadingMeasurement::weightKg).filter(Objects::nonNull).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add));
|
||||
var volume=remote.stream().map(DispatchItem::getVolumeCbm).filter(Objects::nonNull).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add).add(measured.stream().map(LoadingMeasurement::volumeCbm).filter(Objects::nonNull).reduce(java.math.BigDecimal.ZERO,java.math.BigDecimal::add));
|
||||
if(remote.size()+measured.stream().mapToInt(LoadingMeasurement::palletSpaces).sum()>capacity.palletSpaces() || weight.compareTo(capacity.maxLoadKg())>0 || volume.compareTo(capacity.maxVolumeCbm())>0)throw conflict("仓内装载与异地取货合计超过本车容量");
|
||||
}
|
||||
/** 取货照片保存事务复验已确认仓内实载与当前异地申报。 */
|
||||
|
||||
+11
-2
@@ -26,13 +26,13 @@ final class DispatchDestinationAppointments {
|
||||
if (maintenance != null) for (var entry : maintenance.destinationAppointments()) entered.put(entry.destinationKey(),entry.appointmentNo());
|
||||
var result = new ArrayList<FbaAppointment>();
|
||||
destinations.forEach((key,code) -> {
|
||||
var confirmed = booked.stream().filter(a -> key.equals(a.destinationKey()) && "SUCCESS".equals(a.status()) && a.appointmentNo() != null && !a.appointmentNo().isBlank()).toList();
|
||||
var confirmed = booked.stream().filter(a -> key.equals(a.destinationKey()) && ("SUCCESS".equals(a.status()) || "REGISTERED".equals(a.status())) && a.appointmentNo() != null && !a.appointmentNo().isBlank()).toList();
|
||||
var numbers = confirmed.stream().map(FbaAppointment::appointmentNo).distinct().toList();
|
||||
// 草稿暂未填写不能遮挡后来取得的唯一正式确认号,已填写值仍保留。
|
||||
var enteredNumber = entered.get(key);
|
||||
var number = enteredNumber != null ? enteredNumber : numbers.size() == 1 ? numbers.get(0) : null;
|
||||
var matching = confirmed.stream().filter(a -> Objects.equals(number,a.appointmentNo())).toList();
|
||||
var at = matching.stream().map(FbaAppointment::confirmedAt).filter(Objects::nonNull).min(Comparator.naturalOrder()).orElse(null);
|
||||
var at = matching.stream().filter(a -> "SUCCESS".equals(a.status())).map(FbaAppointment::confirmedAt).filter(Objects::nonNull).min(Comparator.naturalOrder()).orElse(null);
|
||||
var shipments = cargoShipments.stream().filter(a -> key.equals(a.destinationKey())).map(com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.CargoShipment::shipmentNo).filter(Objects::nonNull).map(String::trim).filter(s -> !s.isEmpty()).distinct().sorted().toList();
|
||||
result.add(new FbaAppointment(null,null,number,key,code,number == null ? "PENDING" : at == null ? "REGISTERED" : "SUCCESS",at,
|
||||
number == null ? "请填写目的仓 " + code + " 的预约号" : null,null,shipments));
|
||||
@@ -40,6 +40,15 @@ final class DispatchDestinationAppointments {
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** 已约批次可回显已登记编号;只有当前有效收货确认才能提供审核用确认时间。 */
|
||||
static FbaAppointment booked(com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.BatchRow batch, ReceivingWindow confirmed) {
|
||||
var registered = "SUCCESS".equals(batch.status()) && batch.confirmationNo() != null && !batch.confirmationNo().isBlank();
|
||||
var number = confirmed != null ? confirmed.confirmationNo() : registered ? batch.confirmationNo() : null;
|
||||
return new FbaAppointment(batch.batchKey(),batch.batchNo(),number,batch.destinationKey(),batch.warehouseCode(),
|
||||
confirmed != null ? "SUCCESS" : number != null ? "REGISTERED" : "PENDING",confirmed == null ? null : confirmed.startAt(),
|
||||
confirmed == null ? "尚无当前有效收货确认" : null,batch.customerId(),List.of());
|
||||
}
|
||||
|
||||
/** 保存允许暂缺编号,但不能把别的目的仓写入本车。 */
|
||||
static void validate(Maintenance maintenance, List<FbaAppointment> destinations) {
|
||||
if (maintenance == null) return;
|
||||
|
||||
+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;}
|
||||
|
||||
+3
-1
@@ -26,7 +26,7 @@ public class DispatchPdaQueryService {
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public DispatchViews.PdaTask task(Actor actor,String number) {
|
||||
require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号");
|
||||
var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return assemble(actor,rows).get(0);
|
||||
var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");var tasks=assemble(actor,rows);if(tasks.isEmpty())throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return tasks.get(0);
|
||||
}
|
||||
/** 按收货已确认的实托批量读取;未交接的原PLAN不推算实收仓位或箱数。 */
|
||||
private List<DispatchViews.PdaTask> assemble(Actor actor,List<DispatchAggregate> roots) {
|
||||
@@ -42,6 +42,8 @@ public class DispatchPdaQueryService {
|
||||
var consumed=hasRelabel?sourceOrigins.pickedAll(ids).stream().collect(Collectors.toMap(p->p.sourceId(),p->p.packages())):Map.<Long,Long>of();
|
||||
for(var root:roots) {
|
||||
var remote=workflow.remoteItemIds(root);var items=cargo.getOrDefault(root.getId(),List.of()).stream().filter(i->!remote.contains(i.getId())).toList();var detail=new ArrayList<DispatchViews.PdaItem>();
|
||||
// 纯异地取货没有仓内拣货,不向PDA推送空任务。
|
||||
if(items.isEmpty())continue;
|
||||
boolean sourceValid=true;var expectedHold=new HashMap<String,Long>();
|
||||
for(var source:frozen)if(Objects.equals(source.getDispatchOrderId(),root.getId())) {
|
||||
long used=consumed.getOrDefault(source.getId(),0L),left=n(source.getPackageCount())-used;
|
||||
|
||||
+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) {
|
||||
|
||||
+4
@@ -14,6 +14,10 @@ public interface ExceptionWorkflowMapper {
|
||||
/** 本车可登记的派送单,客户跟随真实派送记录。 */
|
||||
@Select("SELECT customer_id,delivery_no FROM ll_delivery_order WHERE dispatch_order_id=#{id} AND status IN ('DELIVERING','REJECTED','DELIVERED') ORDER BY id")
|
||||
List<Delivery> deliveries(long id);
|
||||
/** 取消本单时核对同一客户及派送是否仍有其他活动退回。 */
|
||||
@Select("SELECT COUNT(*) FROM ll_exception_order WHERE customer_id=#{customer} AND delivery_order_id=#{delivery} " +
|
||||
"AND exception_type='RETURN' AND id<>#{excluded} AND status IN ('PENDING','PROCESSING')")
|
||||
long countOtherActiveReturns(@Param("customer") String customer, @Param("delivery") long delivery, @Param("excluded") long excluded);
|
||||
/** 以异常号和冻结仓锁定现场任务。 */
|
||||
@Select("SELECT * FROM ll_exception_order WHERE exception_no=#{number} AND overseas_warehouse_id=#{warehouse} LIMIT 1 FOR UPDATE")
|
||||
ExceptionOrder lockWarehouse(@Param("warehouse") String warehouse, @Param("number") String number);
|
||||
|
||||
+4
@@ -24,6 +24,10 @@ public class ExceptionWorkflowRepository {
|
||||
}
|
||||
/** 查询本车派送归属。 */
|
||||
public List<ExceptionWorkflowMapper.Delivery> deliveries(long id) { return workflow.deliveries(id); }
|
||||
/** 取消单据不释放同派送其他仍在处理的退回批次。 */
|
||||
public boolean hasOtherActiveReturns(String customer, long delivery, long excluded) {
|
||||
return workflow.countOtherActiveReturns(customer,delivery,excluded)>0;
|
||||
}
|
||||
/** 在客户范围锁定异常。 */
|
||||
public ExceptionOrder lock(String customer, String number) { return exceptions.lockByNo(customer,number); }
|
||||
/** 保存人工完结后的异常状态,保留关联单据和实际库存事实。 */
|
||||
|
||||
+10
-3
@@ -36,17 +36,24 @@ public class DispatchExceptionAdapter implements DispatchExceptionPort {
|
||||
if(candidates.isEmpty())throw new ResponseStatusException(HttpStatus.CONFLICT,"本车没有可关联的派送来源");
|
||||
if(warehouse) {
|
||||
var seen=new HashSet<String>();
|
||||
var grouped=new LinkedHashMap<Candidate,List<ExceptionOrderRequests.MarkReturn>>();
|
||||
for(int index=0;index<selected.size();index++) {
|
||||
var selection=selected.get(index);
|
||||
if(selection==null || selection.items()==null || selection.items().isEmpty() || selection.items().size()>200 || !seen.add(selection.deliveryNo()))
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"每张派送单请选择一张来源订单及有效退回货物");
|
||||
if(selection==null || selection.items()==null || selection.items().isEmpty() || selection.items().size()>200
|
||||
|| !seen.add(selection.deliveryNo()+":"+selection.lastMileOrderId()))
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择不重复的来源订单及有效退回货物");
|
||||
var source=candidates.stream().filter(c->Objects.equals(c.deliveryNo(),selection.deliveryNo()) && Objects.equals(c.lastMileOrderId(),selection.lastMileOrderId())).findFirst()
|
||||
.orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"所选来源不属于当前排车"));
|
||||
if(selection.items().stream().anyMatch(i->i==null || i.outboundPalletItemId()==null || i.packageCount()<1))throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"退回明细无效");
|
||||
service.markReturn(source.customerId(),operator,operator,source.deliveryNo(),new ExceptionOrderRequests.MarkReturn(source.lastMileOrderId(),reason,"OVERSEAS_WAREHOUSE",null,
|
||||
// 同派送的多张原订单一次锁定并整批冻结,不能让首单的退回中状态拦住后续订单。
|
||||
var group=grouped.keySet().stream().filter(row->Objects.equals(row.customerId(),source.customerId())
|
||||
&& Objects.equals(row.deliveryNo(),source.deliveryNo())).findFirst().orElse(source);
|
||||
grouped.computeIfAbsent(group,ignored->new ArrayList<>()).add(new ExceptionOrderRequests.MarkReturn(source.lastMileOrderId(),reason,"OVERSEAS_WAREHOUSE",null,
|
||||
selection.items().stream().map(i->new ExceptionOrderRequests.ReturnItem(i.outboundPalletItemId(),i.inventoryPositionId(),i.packageCount(),i.returnCartonId())).toList(),
|
||||
"dispatch:"+dispatchId+":"+java.util.UUID.nameUUIDFromBytes(request.idempotencyKey().getBytes(java.nio.charset.StandardCharsets.UTF_8))+":"+index));
|
||||
}
|
||||
for(var entry:grouped.entrySet()) service.markReturns(entry.getKey().customerId(),operator,operator,
|
||||
entry.getKey().deliveryNo(),entry.getValue());
|
||||
} else for(var source:candidates) service.create(source.customerId(),operator,operator,
|
||||
new ExceptionOrderRequests.Create(source.customerId(),null,source.lastMileOrderNo(),"REFUSED".equals(request.exceptionType().name())?"REJECT":"OTHER",reason,null,null));
|
||||
}
|
||||
|
||||
+46
-6
@@ -216,7 +216,7 @@ public class ExceptionOrderService {
|
||||
ExceptionOrderResponses.Relation relation = relation(scopedCustomerId, request.customerId(),
|
||||
request.inboundCode(), request.lastMileOrderNo());
|
||||
return insert(relation, null, normalizeType(request.exceptionType()), request.reason(), request.packageCount(),
|
||||
request.skuCode(), null, null, "MANUAL", operatorId, operatorName);
|
||||
request.skuCode(), null, null, "MANUAL", operatorId, operatorName, null);
|
||||
}
|
||||
|
||||
/** 按原出库冻结普通仓位或原箱来源,整箱引用与数量在同一派送锁内复核。 */
|
||||
@@ -227,7 +227,44 @@ public class ExceptionOrderService {
|
||||
ExceptionOrderCommand replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim());
|
||||
if (replay != null) return replayCreated(replay, requestHash);
|
||||
DeliveryOrder delivery = lockForReturn(customerId, deliveryNo);
|
||||
return markReturnLocked(customerId, operatorId, operatorName, delivery, request, requestHash, delivery.getStatus());
|
||||
}
|
||||
|
||||
/** 同一派送可一次选择多张原订单;整批共用派送行锁,任一来源失败全部回滚。 */
|
||||
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
|
||||
public List<ExceptionOrderResponses.Created> markReturns(String customerId, String operatorId, String operatorName,
|
||||
String deliveryNo, List<ExceptionOrderRequests.MarkReturn> requests) {
|
||||
if (requests == null || requests.isEmpty() || requests.size() > 200 || requests.stream().anyMatch(request -> request == null
|
||||
|| request.lastMileOrderId() == null || request.idempotencyKey() == null || request.idempotencyKey().isBlank()
|
||||
|| request.items() == null || request.items().isEmpty() || request.items().size() > 200
|
||||
|| request.items().stream().anyMatch(item -> item == null || item.packageCount() < 1))
|
||||
|| requests.stream().map(ExceptionOrderRequests.MarkReturn::idempotencyKey).distinct().count() != requests.size()
|
||||
|| requests.stream().map(ExceptionOrderRequests.MarkReturn::lastMileOrderId).distinct().count() != requests.size())
|
||||
throw badRequest("请选择不重复的来源订单及有效退回货物");
|
||||
var replayed = new ArrayList<ExceptionOrderResponses.Created>();
|
||||
for (var request : requests) {
|
||||
var replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim());
|
||||
if (replay != null) replayed.add(replayCreated(replay, hash(request)));
|
||||
}
|
||||
if (replayed.size() == requests.size()) return List.copyOf(replayed);
|
||||
if (!replayed.isEmpty()) throw conflict("退回批次已有部分命令,请刷新后核对原结果");
|
||||
DeliveryOrder delivery = lockForReturn(customerId, deliveryNo);
|
||||
long packages = requests.stream().flatMap(request -> request.items().stream())
|
||||
.mapToLong(ExceptionOrderRequests.ReturnItem::packageCount).reduce(0L, Math::addExact);
|
||||
long signed = Objects.requireNonNullElse(delivery.getSignedPackages(), 0);
|
||||
if (signed < 0 || delivery.getPackageCount() == null || packages > delivery.getPackageCount() - signed)
|
||||
throw conflict("退回总箱数超过派送未签收数量");
|
||||
var created = new ArrayList<ExceptionOrderResponses.Created>();
|
||||
String originalStatus = delivery.getStatus();
|
||||
for (var request : requests) created.add(markReturnLocked(customerId, operatorId, operatorName, delivery, request, hash(request), originalStatus));
|
||||
return List.copyOf(created);
|
||||
}
|
||||
|
||||
/** 调用方已锁定派送并校验批次总量,这里按原订单冻结真实来源并保存独立异常。 */
|
||||
private ExceptionOrderResponses.Created markReturnLocked(String customerId, String operatorId, String operatorName,
|
||||
DeliveryOrder delivery, ExceptionOrderRequests.MarkReturn request,
|
||||
String requestHash, String originalDeliveryStatus) {
|
||||
String deliveryNo = delivery.getDeliveryNo();
|
||||
List<ExceptionOrderResponses.Relation> candidates = returnCandidates(delivery);
|
||||
ExceptionOrderResponses.Relation selected = candidates.stream()
|
||||
.filter(candidate -> Objects.equals(candidate.lastMileOrderId(), request.lastMileOrderId()))
|
||||
@@ -248,7 +285,7 @@ public class ExceptionOrderService {
|
||||
}
|
||||
ExceptionOrderResponses.Created created = insert(selected, delivery, "RETURN", request.reason(),
|
||||
packages, sku, returnDirection, request.returnLogisticsNo(),
|
||||
"DELIVERY_RETURN", operatorId, operatorName);
|
||||
"DELIVERY_RETURN", operatorId, operatorName, originalDeliveryStatus);
|
||||
|
||||
for (ExceptionOrderRequests.ReturnItem requestedItem : requested) {
|
||||
ExceptionOrderResponses.ReturnSource source = available.stream()
|
||||
@@ -284,7 +321,7 @@ public class ExceptionOrderService {
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Caller keeps the delivery row locked until return creation commits. */
|
||||
/** 调用方持有派送行锁,禁止活动退回批次之间重叠冻结。 */
|
||||
DeliveryOrder lockForReturn(String customerId, String deliveryNo) {
|
||||
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
|
||||
if (delivery == null) throw notFound("派送单不存在");
|
||||
@@ -461,7 +498,7 @@ public class ExceptionOrderService {
|
||||
private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery,
|
||||
String type, String reason, Integer packages, String sku,
|
||||
String direction, String logisticsNo, String sourceType,
|
||||
String operatorId, String operatorName) {
|
||||
String operatorId, String operatorName, String originalDeliveryStatus) {
|
||||
if (packages != null && relation.availablePackages() != null && packages > relation.availablePackages()) {
|
||||
throw conflict("异常箱数不能大于订单箱数 " + relation.availablePackages());
|
||||
}
|
||||
@@ -482,7 +519,8 @@ public class ExceptionOrderService {
|
||||
value.setReturnLogisticsNo(trim(logisticsNo));
|
||||
if (delivery != null) {
|
||||
LastlegOrder tail = relation.lastMileOrderId() == null ? null : lastMileOrders.selectById(relation.lastMileOrderId());
|
||||
value.setReturnContextJson(write(new ReturnContext(delivery.getStatus(), tail == null ? null : tail.getId(),
|
||||
// 同批各单冻结批次开始前的派送状态,支持任意顺序取消并恢复。
|
||||
value.setReturnContextJson(write(new ReturnContext(originalDeliveryStatus, tail == null ? null : tail.getId(),
|
||||
tail == null ? null : tail.getSubOrderStatus(), tail == null ? null : tail.getAbnormal())));
|
||||
}
|
||||
if (StringUtils.hasText(direction)) {
|
||||
@@ -695,6 +733,7 @@ public class ExceptionOrderService {
|
||||
|
||||
private record ReturnContext(String deliveryStatus, Long tailId, String tailStatus, Boolean abnormal) { }
|
||||
|
||||
/** 取消只恢复本原单;同批仍有未取消退回时继续保留派送退回中状态。 */
|
||||
private void restoreCancelledReturn(ExceptionOrder order) {
|
||||
if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return;
|
||||
ReturnContext previous;
|
||||
@@ -706,7 +745,8 @@ public class ExceptionOrderService {
|
||||
if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) throw conflict("关联派送状态已变化,不能取消退回");
|
||||
if (returnItems.findByException(order.getCustomerId(), order.getId()).stream().anyMatch(item -> ReturnReceiptPolicy.received(item) > 0))
|
||||
throw conflict("已有实际退回入仓,不能取消退回事实");
|
||||
delivery.setStatus(previous.deliveryStatus()); delivery.setUpdatedAt(LocalDateTime.now());
|
||||
boolean otherReturns = workflow.hasOtherActiveReturns(order.getCustomerId(), delivery.getId(), order.getId());
|
||||
delivery.setStatus(otherReturns ? "RETURN_PENDING" : previous.deliveryStatus()); delivery.setUpdatedAt(LocalDateTime.now());
|
||||
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); deliveryOrders.updateById(delivery);
|
||||
if (previous.tailId() != null) {
|
||||
LastlegOrder tail = lastMileOrders.selectById(previous.tailId());
|
||||
|
||||
+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());
|
||||
}
|
||||
|
||||
+2
-1
@@ -56,8 +56,9 @@ public final class OwnedBusinessModels {
|
||||
String recordVersion,String dispatchRecordVersion,PhotoSummary photoSummary,Actions actions,List<String> sourceIssues,String taskType) {}
|
||||
public record Summary(int dispatchCount,int afterLoadingCount,int deliveringCount,int deliveredCount) {}
|
||||
public record ListResult(List<Row> records,int page,int size,int total,Summary summary) {}
|
||||
/** 散板按商业订单展示,关联托盘与目的地完整保留,箱数只累加真实来源贡献。 */
|
||||
public record Cargo(String sourceKey,String customerId,String customerName,String orderNo,
|
||||
String inboundCode,String palletNo,String destinationKey,String packages) {}
|
||||
String inboundCode,String palletNo,String destinationKey,String packages,List<String> destinationKeys) {}
|
||||
public record Detail(Row row,DispatchViews.Detail dispatch,List<Cargo> cargo,List<Photo> photos) {}
|
||||
public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,
|
||||
Operations operations,List<Photo> photos) {}
|
||||
|
||||
+28
-4
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Search;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.CargoRow;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
|
||||
@@ -68,14 +69,37 @@ public class OwnedBusinessQueryService {
|
||||
public Detail detailForWrite(Actor actor,Source source) {
|
||||
return detailForWrite(actor,source,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 写回执也执行固定归属校验,调用者已核对本域独立写权限。 */
|
||||
/** 写回执也执行固定归属校验;散板明细复用原排车公开的商业订单归属。 */
|
||||
public Detail detailForWrite(Actor actor,Source source,OwnedBusinessScope scope) {
|
||||
eligible(source,scope);var ids=List.of(source.getId());var cargo=repository.cargo(ids);
|
||||
var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor,scope);
|
||||
var original=dispatch.detail(new DispatchContracts.Actor(actor.operatorId(),ActorMode.PLATFORM,null),source.getDispatchNo());
|
||||
return new Detail(row,original,cargo.stream().map(c->new Cargo(c.getSourceKey(),c.getCustomerId(),c.getCustomerName(),
|
||||
c.getOrderNo(),c.getInboundCode(),c.getPalletNo(),projection.destinationKey(c),String.valueOf(c.getPackageCount()))).toList(),
|
||||
projection.photos(repository.record(source.getId())));
|
||||
var looseboardOrders=new HashMap<List<String>,String>();
|
||||
if(original!=null && original.orderDetails()!=null)for(var order:original.orderDetails()) {
|
||||
if(!"LOCAL_LOOSEBOARD".equals(order.sourceBusinessType()) || order.sourceBusinessNo()==null)continue;
|
||||
for(var key:order.sourceKeys())looseboardOrders.put(List.of(order.customerId(),key),order.sourceBusinessNo());
|
||||
}
|
||||
return new Detail(row,original,cargoDetails(cargo,looseboardOrders),projection.photos(repository.record(source.getId())));
|
||||
}
|
||||
|
||||
/** 商业订单由排车公开契约确认,普通货物保持原行,不能按编号前缀猜测散板。 */
|
||||
private List<Cargo> cargoDetails(List<CargoRow> cargo,Map<List<String>,String> looseboardOrders) {
|
||||
var groups=new LinkedHashMap<List<String>,List<CargoRow>>();
|
||||
for(var item:cargo) {
|
||||
var order=looseboardOrders.get(List.of(item.getCustomerId(),item.getSourceKey()));
|
||||
var key=order==null?List.of("SOURCE",item.getCustomerId(),item.getSourceKey()):List.of("LOOSEBOARD",item.getCustomerId(),order);
|
||||
groups.computeIfAbsent(key,ignored->new ArrayList<>()).add(item);
|
||||
}
|
||||
return groups.entrySet().stream().map(entry->{
|
||||
var rows=entry.getValue();var first=rows.get(0);boolean looseboard="LOOSEBOARD".equals(entry.getKey().get(0));
|
||||
var destinations=rows.stream().map(projection::destinationKey).distinct().toList();
|
||||
var pallets=rows.stream().map(CargoRow::getPalletNo).filter(Objects::nonNull).distinct().toList();
|
||||
// 同一来源只贡献一次箱数,实托码展示完整集合,不复制整托箱数。
|
||||
var sources=rows.stream().collect(Collectors.toMap(CargoRow::getSourceKey,c->c,(a,b)->a,LinkedHashMap::new)).values();
|
||||
String packages=sources.stream().anyMatch(c->c.getPackageCount()==null)?null:Long.toString(sources.stream().mapToLong(CargoRow::getPackageCount).sum());
|
||||
return new Cargo(first.getSourceKey(),first.getCustomerId(),first.getCustomerName(),looseboard?entry.getKey().get(2):first.getOrderNo(),
|
||||
looseboard?null:first.getInboundCode(),pallets.isEmpty()?null:String.join("、",pallets),destinations.get(0),packages,destinations);
|
||||
}).toList();
|
||||
}
|
||||
/** 缺少历史归属必须明确拒绝,不能按当前车辆主档降级猜测。 */
|
||||
public Source requireOwned(String number) {
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ public interface InboundPlanQueryMapper {
|
||||
String SCHEDULED_DISPATCH = "EXISTS (SELECT 1 FROM ll_dispatch_item di" +
|
||||
" JOIN ll_dispatch_order dispatch ON dispatch.id=di.dispatch_order_id" +
|
||||
" WHERE di.customer_id=p.customer_id AND di.order_id=l.order_id" +
|
||||
" AND dispatch.status<>'CANCELLED')";
|
||||
" AND dispatch.status<>'CANCELLED' AND COALESCE(dispatch.task_type,'DELIVERY') NOT IN ('PICKUP','LOCAL_TRUCK'))";
|
||||
|
||||
String FROM_AND_FILTER = " FROM ll_inbound_plan_line l" +
|
||||
" JOIN ll_inbound_plan p ON p.id=l.plan_id" +
|
||||
|
||||
+2
-1
@@ -22,7 +22,8 @@ public final class LocalLooseboardResponses {
|
||||
String operationType, String fromStatus, String toStatus, String operatorId,
|
||||
String idempotencyKey, String changeDetail, String remark, LocalDateTime createdAt) { }
|
||||
|
||||
/** 详情独立返回全部有效排车,同车多托合并为一个单号。 */
|
||||
public record Detail(
|
||||
Summary summary, String originAddress, String destinationAddress, String remark,
|
||||
String createdBy, List<History> histories) { }
|
||||
String createdBy, List<History> histories, List<String> dispatchNos) { }
|
||||
}
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.looseboard.port;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 散板原单的排车关联只读入口,不向客户暴露整车货物。 */
|
||||
public interface LooseboardDispatches {
|
||||
/** 按真实客户和散板原单返回去重后的有效排车单号。 */
|
||||
List<String> looseboardDispatches(String customerId, String orderNo);
|
||||
}
|
||||
+3
-2
@@ -48,6 +48,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
||||
history(order,"TRANSPORT_PHOTO",from,next,operatorId,null,null,"按排车取货/送达照片更新,累计送达"+deliveredPackages+"箱",now);
|
||||
}
|
||||
@Resource private com.baiyue.lastleg.looseboard.port.LooseboardReceiving receiving;
|
||||
@Resource private com.baiyue.lastleg.looseboard.port.LooseboardDispatches dispatches;
|
||||
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPool;
|
||||
public static final String PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE";
|
||||
public static final String DISPATCHED = "DISPATCHED";
|
||||
@@ -278,7 +279,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
||||
return order;
|
||||
}
|
||||
|
||||
/** 装配需求详情、真实承运安排与不可变历史。 */
|
||||
/** 装配需求详情、真实承运安排、全部有效排车与不可变历史。 */
|
||||
private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order) {
|
||||
return new LocalLooseboardResponses.Detail(summary(order), order.getOriginAddress(),
|
||||
order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(),
|
||||
@@ -286,7 +287,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
||||
.map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(),
|
||||
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
|
||||
value.getChangeDetail(), value.getRemark(),
|
||||
value.getCreatedAt())).toList());
|
||||
value.getCreatedAt())).toList(), dispatches.looseboardDispatches(order.getCustomerId(), order.getOrderNo()));
|
||||
}
|
||||
|
||||
/** 构造同客户商业订单的承运查询身份。 */
|
||||
|
||||
@@ -51,14 +51,15 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
|
||||
String externalBookingNo, boolean poolSource) { }
|
||||
|
||||
// Manual node snapshots replace container nodes permanently for the order.
|
||||
String SAILING_STAGE_FILTER_SQL = "(ll_order.order_type='FIRST_LEG' AND " +
|
||||
String SAILING_STAGE_VALUE_SQL = "(ll_order.order_type='FIRST_LEG' AND " +
|
||||
"(CASE WHEN COALESCE(ll_order.manual_node_override,FALSE)=TRUE THEN " +
|
||||
"CASE WHEN EXISTS (SELECT 1 FROM ll_order_manual_node n WHERE n.order_id=ll_order.id " +
|
||||
"AND n.customer_id=ll_order.customer_id AND n.node_code='ACTUAL_DEPARTURE' AND n.node_at IS NOT NULL) " +
|
||||
"THEN 1 ELSE 0 END ELSE " +
|
||||
"CASE WHEN EXISTS (SELECT 1 FROM ll_container_shipping_gate g " +
|
||||
"WHERE g.container_booking_id=ll_order.source_container_booking_id " +
|
||||
"AND g.node_sequence>=40 AND g.actual_at IS NOT NULL) THEN 1 ELSE 0 END END)={0})";
|
||||
"AND g.node_sequence>=40 AND g.actual_at IS NOT NULL) THEN 1 ELSE 0 END END)";
|
||||
String SAILING_STAGE_FILTER_SQL = SAILING_STAGE_VALUE_SQL + "={0})";
|
||||
|
||||
@Select("<script>SELECT r.lastleg_order_id,r.relabel_no,r.status,r.notified_package_count AS quantity, " +
|
||||
"CASE WHEN NOT EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=r.id AND ri.operation_type<>'CARTON_LABEL') THEN '箱' ELSE '件' END AS quantity_unit,r.created_at " +
|
||||
@@ -71,11 +72,18 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
|
||||
|
||||
@Select("<script>SELECT o.id AS orderId,CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di " +
|
||||
"JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id " +
|
||||
"WHERE di.customer_id=o.customer_id AND di.order_id=o.id AND d.status!='CANCELLED') " +
|
||||
"WHERE di.customer_id=o.customer_id AND di.order_id=o.id AND d.status!='CANCELLED' AND COALESCE(d.task_type,'DELIVERY') NOT IN ('PICKUP','LOCAL_TRUCK')) " +
|
||||
"THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END AS dispatchStatus FROM ll_order o WHERE o.id IN " +
|
||||
"<foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
|
||||
List<DispatchStatusRow> findDispatchStatuses(@Param("orderIds") List<Long> orderIds);
|
||||
|
||||
/** 已开船来源键用于聚合前去重,保持客户隔离和人工节点覆盖规则。 */
|
||||
@Select("<script>SELECT DISTINCT customer_id,source_order_no FROM ll_order WHERE source_order_no IS NOT NULL " +
|
||||
"<if test='customer != null'>AND customer_id=#{customer} </if>AND " +
|
||||
SAILING_STAGE_VALUE_SQL + "=1)" + "</script>")
|
||||
List<DepartedSource> departedSources(@Param("customer") String customer);
|
||||
record DepartedSource(String customerId, String sourceOrderNo) { }
|
||||
|
||||
record DispatchStatusRow(Long orderId, String dispatchStatus) { }
|
||||
|
||||
record OrderWeightRow(Long orderId, BigDecimal grossWeightKg, BigDecimal netWeightKg) { }
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.lastleg.repository;
|
||||
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 尾程实际船节点优先于头程摘要中缺失的开船时间。 */
|
||||
@Repository
|
||||
public class PreDepartureOrderRepository {
|
||||
private final LastlegOrderMapper mapper;
|
||||
/** 仅读本服务尾程事实,不读取订单服务数据库。 */
|
||||
public PreDepartureOrderRepository(LastlegOrderMapper mapper) { this.mapper = mapper; }
|
||||
/** 按客户与来源订单组成去重键,在分页前排除已开船记录。 */
|
||||
public Set<String> departedKeys(String customer) {
|
||||
return mapper.departedSources(customer).stream()
|
||||
.map(row -> row.customerId() + ":" + row.sourceOrderNo()).collect(Collectors.toSet());
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,7 @@ public class LastlegOrderService {
|
||||
@Resource private ReturnOrderReferenceResolver returnOrderReferences;
|
||||
@Resource private com.baiyue.lastleg.transfer.service.TransferQuantityService transferQuantities;
|
||||
@Resource private OrderPreDepartureClient orderPreDepartureClient;
|
||||
@Resource private com.baiyue.lastleg.repository.PreDepartureOrderRepository preDepartureOrders;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalServiceToken;
|
||||
|
||||
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
|
||||
@@ -101,12 +102,7 @@ public class LastlegOrderService {
|
||||
|
||||
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query, String relabelNo) {
|
||||
if (query.sailingStage() == LastlegOrderRequests.SailingStage.BEFORE_DEPARTURE) {
|
||||
Long customer = parseLong(customerId);
|
||||
var response = orderPreDepartureClient.page(internalServiceToken, customer, query.orderNo(), query.inboundCode(),
|
||||
query.page().intValue(), query.size().intValue());
|
||||
if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "订单服务暂时不可用");
|
||||
List<LastlegOrderSummary> rows = response.data().records().stream().map(this::preDepartureSummary).toList();
|
||||
return new PageResult<>(rows, response.data().page(), response.data().size(), response.data().total(), response.data().pages());
|
||||
return preDeparturePage(customerId, query);
|
||||
}
|
||||
if (query.sailingStage() == null && relabelNo == null) return aggregateAllPage(customerId, query);
|
||||
Page<LastlegOrder> page = orderMapper.selectPage(
|
||||
@@ -115,6 +111,27 @@ public class LastlegOrderService {
|
||||
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
|
||||
}
|
||||
|
||||
/** 开船前先扣除尾程已有实际开船事实,再分页,避免两页签重复及总数失真。 */
|
||||
private PageResult<LastlegOrderSummary> preDeparturePage(String customerId, LastlegOrderRequests.Query query) {
|
||||
var departed = preDepartureOrders.departedKeys(customerId);
|
||||
var records = new java.util.ArrayList<LastlegOrderSummary>();
|
||||
for (int sourcePage = 1; ; sourcePage++) {
|
||||
var response = orderPreDepartureClient.page(internalServiceToken, parseLong(customerId),
|
||||
query.orderNo(), query.inboundCode(), sourcePage, 2000);
|
||||
if (response == null || !response.successful() || response.data() == null)
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "订单服务暂时不可用");
|
||||
response.data().records().stream()
|
||||
.filter(row -> !departed.contains(orderKey(String.valueOf(row.customerId()), row.orderNo())))
|
||||
.map(this::preDepartureSummary).forEach(records::add);
|
||||
if (sourcePage >= response.data().pages()) break;
|
||||
}
|
||||
int from = (int)Math.min((query.page() - 1) * query.size(), records.size());
|
||||
int to = (int)Math.min(from + query.size(), records.size());
|
||||
long total = records.size();
|
||||
return new PageResult<>(records.subList(from, to), query.page(), query.size(), total,
|
||||
total == 0 ? 0 : (total + query.size() - 1) / query.size());
|
||||
}
|
||||
|
||||
/** 全部及已开船列表使用同一组业务事实,避免聚合列表丢失并托、排车和换标信息。 */
|
||||
private List<LastlegOrderSummary> nativeSummaries(List<LastlegOrder> orders) {
|
||||
Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(orders);
|
||||
|
||||
@@ -3,6 +3,8 @@ info:
|
||||
title: 本土散板排车照片履约
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
管理资料按同客户散板原单聚合,每单展示一块并汇总本车各托箱数。itemId为该订单在本车的代表明细,
|
||||
同单照片一次保存并在同一事务保留各托证据;不同客户或不同订单不能共用节点照片,分车仍分别履约。
|
||||
仅同客户权威关联的本土散板明细进入照片节点。全车为散板且尚无真实扫码时,审核通过后逐票保存取货照片,
|
||||
全部齐全才显示取货完成按钮;显式确认后从LOADING进入DELIVERING。不生成库存、PDA拣货或仓库出库事实。逐票保存送达照片后自动DELIVERED并结案释放车辆司机。
|
||||
混排从AFTER_LOADING或DELIVERING可逐票保存取货及送达照片;异地货同票须先取货再送达,不同票不限制取送顺序。
|
||||
|
||||
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: 派送异常退回入库
|
||||
version: 1.0.0
|
||||
description: 排车异常冻结原出库货物,异常单生成退回尾程单后进入PDA退货入库列表。现场展示异常单号、车牌和待收箱数,点击开始作业,不需要回单号或扫码找单。建单不增加库存,分批实收调用原来源RETURN_STOCK_IN或原箱恢复,异常和退回尾程单收齐后完成。
|
||||
description: 排车异常允许一次选择同派送下多张原订单,逐原单生成异常并冻结对应原出库货物,整批校验与落单原子提交。异常单生成退回尾程单后进入PDA退货入库列表。现场展示异常单号、车牌和待收箱数,点击开始作业,不需要回单号或扫码找单。建单不增加库存,分批实收调用原来源RETURN_STOCK_IN或原箱恢复,异常和退回尾程单收齐后完成。回仓扫描当前仓有效托盘及其实际库位,不要求使用已出库报废的原托;同键实收重放不重复增加库存。
|
||||
paths:
|
||||
/api/platform/v1/last-leg/exception-orders/return-inbound-plans:
|
||||
post:
|
||||
|
||||
@@ -793,6 +793,7 @@ components:
|
||||
Cargo:
|
||||
type: "object"
|
||||
x-java-record: "OwnedBusinessModels.Cargo"
|
||||
description: 散板按客户和商业订单一行展示,关联托盘码去重连接,箱数按本单来源累加;其他货物保持原来源行。
|
||||
required:
|
||||
- "sourceKey"
|
||||
- "customerId"
|
||||
@@ -815,10 +816,19 @@ components:
|
||||
nullable: true
|
||||
palletNo:
|
||||
type: "string"
|
||||
nullable: true
|
||||
description: 关联的完整实托码集合,以顿号连接;未提供保持未知。
|
||||
destinationKey:
|
||||
type: "string"
|
||||
description: 首个目的地键;展示使用完整destinationKeys集合。
|
||||
destinationKeys:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: 本订单全部真实目的地,不能丢失多目的地信息。
|
||||
packages:
|
||||
type: "string"
|
||||
nullable: true
|
||||
Detail:
|
||||
type: "object"
|
||||
x-java-record: "OwnedBusinessModels.Detail"
|
||||
|
||||
@@ -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命令,校验版本、幂等、同仓及整托占用,自动刷新目的站和货量并失效审核,不执行出库拣货。
|
||||
@@ -4904,8 +4904,8 @@ components:
|
||||
fleetArrangement: { $ref: '#/components/schemas/FleetArrangement' }
|
||||
LooseboardDetail:
|
||||
type: object
|
||||
description: 承运安排位于summary.fleetArrangement,与列表摘要使用同一真实来源;根对象不重复该字段。池来源状态只能由实际履约推进。
|
||||
required: [summary, histories]
|
||||
description: 承运安排位于summary.fleetArrangement;dispatchNos独立按本客户散板原单的真实来源返回全部有效排车,不依赖车队预订,同车多托去重、分车全部展示、取消排车排除;未排车返回空数组。池来源状态只能由实际履约推进。
|
||||
required: [summary, histories, dispatchNos]
|
||||
properties:
|
||||
summary: { $ref: '#/components/schemas/LooseboardSummary' }
|
||||
originAddress: { type: string, nullable: true }
|
||||
@@ -4913,6 +4913,7 @@ components:
|
||||
remark: { type: string, nullable: true }
|
||||
createdBy: { type: string, nullable: true }
|
||||
histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } }
|
||||
dispatchNos: { type: array, uniqueItems: true, items: { type: string }, description: 本客户当前散板单关联的全部有效排车单号,不授予客户整车详情访问权限 }
|
||||
FleetArrangement:
|
||||
type: object
|
||||
nullable: true
|
||||
@@ -6771,6 +6772,7 @@ components:
|
||||
DispatchOrderDetail:
|
||||
type: object
|
||||
x-java-record: DispatchViews.OrderDetail
|
||||
description: 散板按客户和权威商业订单汇总一行,实托去重计数;重量体积仅完整可分摊时汇总,未知不补零。普通来源保持原展示语义。
|
||||
properties:
|
||||
customerId:
|
||||
type: string
|
||||
@@ -6808,6 +6810,11 @@ components:
|
||||
sourceBusinessNo:
|
||||
type: string
|
||||
nullable: true
|
||||
sourceKeys:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: 本展示行包含的全部原来源键,只用于只读关联,不能作为新增或删除货物的命令身份。
|
||||
required:
|
||||
- customerId
|
||||
- customerName
|
||||
@@ -6821,6 +6828,7 @@ components:
|
||||
- sourceKey
|
||||
- sourceBusinessType
|
||||
- sourceBusinessNo
|
||||
- sourceKeys
|
||||
DispatchDetail:
|
||||
type: object
|
||||
x-java-record: DispatchViews.Detail
|
||||
@@ -8504,7 +8512,9 @@ components:
|
||||
pattern: ^[1-9][0-9]*$
|
||||
returnSelections:
|
||||
type: array
|
||||
description: 退回海外仓必填。来源必须属于本车派送,箱数不超过原拣货及未签收余量。
|
||||
description: 退回海外仓必填,可同时选择同一派送下多张原订单,每个deliveryNo和lastMileOrderId组合只允许一项,各原单分别生成异常。同一派送在行锁内核验批次总退回量不超过未签收余量;所有来源属于本车及对应原单,任一失败整批回滚,原幂等键重放不重复建单或冻结。
|
||||
minItems: 1
|
||||
maxItems: 200
|
||||
items:
|
||||
type: object
|
||||
required: [deliveryNo, lastMileOrderId, items]
|
||||
|
||||
+4
-4
@@ -11,7 +11,7 @@ import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class MerchantTransportPhotoMapperTest {
|
||||
/** 同编号、混装和撤销安排均不能扩大客户读取范围,实际查询使用正式Mapper。 */
|
||||
/** 同单分托去重且分车全部保留;同编号、混装和撤销安排不扩大客户读取范围。 */
|
||||
@Test void queriesOnlyExactCustomerOrderAndActiveDispatches() throws Exception {
|
||||
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
var db=new JdbcTemplate(data);
|
||||
@@ -24,13 +24,13 @@ class MerchantTransportPhotoMapperTest {
|
||||
db.update("INSERT INTO ll_local_looseboard_order VALUES('C1','LB1',1),('C2','LB1',2)");
|
||||
db.update("INSERT INTO ll_inbound_plan VALUES(11,'C1',1),(12,'C2',2)");
|
||||
db.update("INSERT INTO ll_inbound_plan_pallet VALUES(21,11,'C1'),(22,12,'C2')");
|
||||
db.update("INSERT INTO ll_dispatch_order VALUES(31,'PC1','ACTIVE','DELIVERY'),(32,'PC2','CANCELLED','DELIVERY'),(33,'PC3','ACTIVE','LOCAL_TRUCK')");
|
||||
db.update("INSERT INTO ll_dispatch_item VALUES(21,'C1',31),(22,'C2',31),(21,'C1',32),(22,'C1',33)");
|
||||
db.update("INSERT INTO ll_dispatch_order VALUES(31,'PC1','ACTIVE','DELIVERY'),(32,'PC2','CANCELLED','DELIVERY'),(33,'PC3','ACTIVE','LOCAL_TRUCK'),(34,'PC4','ACTIVE','DELIVERY')");
|
||||
db.update("INSERT INTO ll_dispatch_item VALUES(21,'C1',31),(21,'C1',31),(22,'C2',31),(21,'C1',32),(22,'C1',33),(21,'C1',34)");
|
||||
db.update("INSERT INTO ll_dispatch_truck_task VALUES('PC3','C1','TR1','C1:TR1'),('PC2','C1','TR1','C1:TR1'),('PC1','C2','TR1','C2:TR1')");
|
||||
var config=new MybatisConfiguration();config.addMapper(MerchantTransportPhotoMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet();
|
||||
var repository=new MerchantTransportPhotoRepository(new SqlSessionTemplate(factory.getObject()).getMapper(MerchantTransportPhotoMapper.class));
|
||||
assertThat(repository.looseboardDispatches("C1","LB1")).containsExactly("PC1");
|
||||
assertThat(repository.looseboardDispatches("C1","LB1")).containsExactly("PC1","PC4");
|
||||
assertThat(repository.looseboardDispatches("C3","LB1")).isEmpty();
|
||||
assertThat(repository.truckDispatches("C1","TR1")).containsExactly("PC3");
|
||||
assertThat(repository.truckDispatches("C2","TR1")).isEmpty();
|
||||
|
||||
+17
@@ -9,6 +9,23 @@ import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** 本车同目的仓跨客户只填一次,先约自动带号,先排可暂缺。 */
|
||||
class DispatchDestinationAppointmentsTest {
|
||||
/** 已预约的编号不能因尚无有效收货确认而丢失,也不能冒充确认时间。 */
|
||||
@Test void registeredBatchNumberIsVisibleWithoutInventingReceivingConfirmation() {
|
||||
var batch=new com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.BatchRow(1L,2L,"FBA_APPOINTMENT:1","AP-1","A","SOURCE","FBA:ONT8",22,"ISA-BOOKED","SUCCESS","ONT8",java.time.LocalDateTime.parse("2026-11-05T10:00:00"),1L);
|
||||
var booked=DispatchDestinationAppointments.booked(batch,null);
|
||||
assertThat(booked.status()).isEqualTo("REGISTERED");
|
||||
assertThat(booked.confirmedAt()).isNull();
|
||||
var rows=resolve(List.of(item("A","ONT8")),maintenance(new DestinationAppointment("FBA:ONT8",null)),List.of(booked));
|
||||
assertThat(rows).singleElement().satisfies(a->{
|
||||
assertThat(a.appointmentNo()).isEqualTo("ISA-BOOKED");
|
||||
assertThat(a.status()).isEqualTo("REGISTERED");assertThat(a.confirmedAt()).isNull();
|
||||
});
|
||||
var window=new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:1","AP-1","A","FBA:ONT8","ISA-CURRENT",java.time.Instant.parse("2026-11-06T10:00:00Z"),null,"AG-1",1);
|
||||
var confirmed=DispatchDestinationAppointments.booked(batch,window);
|
||||
assertThat(confirmed.appointmentNo()).isEqualTo("ISA-CURRENT");
|
||||
assertThat(confirmed.confirmedAt()).isEqualTo(window.startAt());
|
||||
assertThat(confirmed.status()).isEqualTo("SUCCESS");
|
||||
}
|
||||
@Test void groupsMultipleCustomersAndPalletsByDestination() {
|
||||
var rows=resolve(List.of(item("A","ONT8"),item("B","ONT8"),item("A","LGB8")),null,List.of());
|
||||
assertThat(rows).extracting(FbaAppointment::destinationWarehouseCode).containsExactly("LGB8","ONT8");
|
||||
|
||||
+53
@@ -8,12 +8,64 @@ import com.baiyue.lastleg.dispatch.port.*;
|
||||
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.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class DispatchLooseboardDisplayServiceTest {
|
||||
/** 维护车辆与排车详情共用订单投影,同单两托只展示一行且未知重量不补零。 */
|
||||
@Test void looseboardOrderCombinesPalletsAndRetainsAllSourceKeys() {
|
||||
var repository=mock(LooseboardCargoRepository.class);
|
||||
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB202611050003",11L),new BusinessSource("A","ZY2","LB202611050002",13L)));
|
||||
var first=item("A","ZY1",11L,"ZY1");var second=item("A","ZY1",12L,"ZY1");var other=item("A","ZY2",13L,"ZY2");
|
||||
first.setVolumeCbm(new BigDecimal("5"));second.setVolumeCbm(new BigDecimal("5"));other.setVolumeCbm(new BigDecimal("4"));
|
||||
var result=details(repository,List.of(first,second,other));
|
||||
assertThat(result).hasSize(2);assertThat(result.get(0).orderNo()).isEqualTo("LB202611050003");
|
||||
assertThat(result.get(0).palletCount()).isEqualTo(2);assertThat(result.get(0).volumeCbm()).isEqualByComparingTo("10");
|
||||
assertThat(result.get(0).grossWeightKg()).isNull();assertThat(result.get(0).sourceKeys()).containsExactly("PLAN:11","PLAN:12");
|
||||
assertThat(result.get(1).palletCount()).isEqualTo(1);assertThat(result.get(1).volumeCbm()).isEqualByComparingTo("4");
|
||||
assertThat(first.getOrderNo()).isEqualTo("ZY1");assertThat(second.getVolumeCbm()).isEqualByComparingTo("5");
|
||||
}
|
||||
/** 同号跨客户不能合并,普通来源不能仅因编号类似散板而变成散板。 */
|
||||
@Test void sameOrderNumberKeepsCustomerAndOrdinarySourceBoundaries() {
|
||||
var repository=mock(LooseboardCargoRepository.class);
|
||||
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",11L),new BusinessSource("B","ZY1","LB1",12L)));
|
||||
var result=details(repository,List.of(item("A","ZY1",11L,"ZY1"),item("B","ZY1",12L,"ZY1"),item("C","LB1",13L,"REAL"),item("C","LB1",14L,"REAL")));
|
||||
assertThat(result).hasSize(4);assertThat(result).extracting(DispatchViews.OrderDetail::customerId).containsExactly("A","B","C","C");
|
||||
assertThat(result.get(2).sourceBusinessType()).isNull();assertThat(result.get(2).containerNo()).isEqualTo("REAL");
|
||||
}
|
||||
/** 同一实托关联多个来源只计一托和一次整托测量;缺测量的订单总量仍未知。 */
|
||||
@Test void sharedPhysicalPalletIsCountedOnceAndPartialMeasurementRemainsUnknown() {
|
||||
var repository=mock(LooseboardCargoRepository.class);
|
||||
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",11L)));
|
||||
var first=item("A","ZY1",11L,"ZY1");var second=item("A","ZY1",12L,"ZY1");
|
||||
first.setWarehousePalletId(91L);second.setWarehousePalletId(91L);
|
||||
first.setGrossWeightKg(new BigDecimal("30"));second.setGrossWeightKg(new BigDecimal("30.00"));
|
||||
first.setVolumeCbm(new BigDecimal("5"));second.setVolumeCbm(new BigDecimal("5.0"));
|
||||
var combined=details(repository,List.of(first,second)).get(0);
|
||||
assertThat(combined.palletCount()).isEqualTo(1);assertThat(combined.volumeCbm()).isEqualByComparingTo("5");assertThat(combined.grossWeightKg()).isEqualByComparingTo("30");
|
||||
second.setWarehousePalletId(92L);second.setVolumeCbm(null);
|
||||
var partial=details(repository,List.of(first,second)).get(0);
|
||||
assertThat(partial.palletCount()).isEqualTo(2);assertThat(partial.volumeCbm()).isNull();assertThat(partial.grossWeightKg()).isEqualByComparingTo("60");
|
||||
second.setWarehousePalletId(91L);second.setGrossWeightKg(new BigDecimal("31"));
|
||||
assertThat(details(repository,List.of(first,second)).get(0).grossWeightKg()).isNull();
|
||||
}
|
||||
/** 混托多订单无法分摊测量时,不给散板复制整托重量、体积或托数。 */
|
||||
@Test void sharedPalletWithMultipleOrdersDoesNotInventPerOrderMeasurements() {
|
||||
var repository=mock(LooseboardCargoRepository.class);
|
||||
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",11L)));
|
||||
var mixed=item("A","ZY1",11L,"ZY1");mixed.setOrderRefs("ZY1,OTHER");mixed.setGrossWeightKg(new BigDecimal("30"));mixed.setVolumeCbm(new BigDecimal("5"));
|
||||
var result=details(repository,List.of(mixed));
|
||||
assertThat(result).hasSize(1);assertThat(result.get(0).palletCount()).isNull();assertThat(result.get(0).grossWeightKg()).isNull();assertThat(result.get(0).volumeCbm()).isNull();
|
||||
}
|
||||
/** 调用正式排车关联订单投影,避免为聚合规则复制一份测试实现。 */
|
||||
private List<DispatchViews.OrderDetail> details(LooseboardCargoRepository repository,List<DispatchItem> cargo) {
|
||||
var aggregate=new DispatchAggregateService(null,null,null,null,null,null,new ObjectMapper());
|
||||
ReflectionTestUtils.setField(aggregate,"looseboardDisplay",new DispatchLooseboardDisplayService(repository));
|
||||
return ReflectionTestUtils.invokeMethod(aggregate,"orderDetails",cargo);
|
||||
}
|
||||
/** 历史排车仍按真实关联显示LB原单;不把内部ZY号代填为入仓码或集装箱号。 */
|
||||
@Test void historicalDetailsUseBusinessOrderAndNoInventedContainer() {
|
||||
var repository=mock(LooseboardCargoRepository.class);
|
||||
@@ -64,6 +116,7 @@ class DispatchLooseboardDisplayServiceTest {
|
||||
/** 构造旧排车保存的履约快照,验证读取时纠正且不改原来源。 */
|
||||
private DispatchItem item(String customer,String number,long pallet,String container) {
|
||||
var item=new DispatchItem();item.setCustomerId(customer);item.setOrderNo(number);item.setOrderRefs(number);item.setPlanPalletId(pallet);item.setInboundCode(number);
|
||||
item.setSourceKey("PLAN:"+pallet);
|
||||
item.setSourceSnapshotJson("{\"containerNo\":\""+container+"\",\"planNo\":\"plan\"}");return item;
|
||||
}
|
||||
}
|
||||
|
||||
+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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -62,6 +62,13 @@ class DispatchPdaReadPersistenceTest {
|
||||
assertThat(task.items()).allSatisfy(item->assertThat(item.positions()).hasSize(1));
|
||||
assertThatThrownBy(()->f.tx.execute(s->queries.task(new DispatchContracts.Actor("foreign",ActorMode.PDA,"99"),created.dispatchNo())))
|
||||
.isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("当前绑定仓无此排车作业");
|
||||
for (String type : List.of("LOCAL_TRUCK", "PICKUP")) {
|
||||
f.db.update("UPDATE ll_dispatch_order SET task_type=?", type);
|
||||
assertThat(f.tx.<List<DispatchViews.PdaTask>>execute(s->queries.list(actor,null))).isEmpty();
|
||||
assertThatThrownBy(()->f.tx.execute(s->queries.task(actor,created.dispatchNo())))
|
||||
.isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
||||
}
|
||||
f.db.update("UPDATE ll_dispatch_order SET task_type='DELIVERY'");
|
||||
f.db.update("DELETE FROM ll_dispatch_pda_pick_order WHERE customer_id='202'");
|
||||
assertThat(f.tx.execute(s->queries.task(actor,created.dispatchNo())).pickingReconciled()).isFalse();
|
||||
}
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -207,7 +207,7 @@ class ExceptionOrderLifecycleTest {
|
||||
var order = order("PENDING", 1);
|
||||
order.setExceptionType("RETURN"); order.setDeliveryNo("DL-1");
|
||||
order.setReturnContextJson("{\"deliveryStatus\":\"DELIVERED\",\"tailId\":null,\"tailStatus\":null,\"abnormal\":null}");
|
||||
var delivery = new DeliveryOrder(); delivery.setStatus("RETURN_PENDING"); delivery.setVersion(3L);
|
||||
var delivery = new DeliveryOrder(); delivery.setId(71L); delivery.setStatus("RETURN_PENDING"); delivery.setVersion(3L);
|
||||
var signedAt = LocalDateTime.of(2026, 9, 1, 12, 0); delivery.setSignedAt(signedAt);
|
||||
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
|
||||
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
|
||||
|
||||
+72
-2
@@ -36,7 +36,7 @@ class ReturnCartonReturnPersistenceTest {
|
||||
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
db=new JdbcTemplate(data);tx=new TransactionTemplate(new DataSourceTransactionManager(data));
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
|
||||
var mappers=List.of(ExceptionOrderMapper.class,ExceptionOrderCommandMapper.class,com.baiyue.lastleg.mapper.LastlegOrderMapper.class,com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class,ExceptionReturnItemMapper.class,ReturnCartonStockReceiptMapper.class,
|
||||
var mappers=List.of(ExceptionWorkflowMapper.class,ExceptionOrderMapper.class,ExceptionOrderCommandMapper.class,com.baiyue.lastleg.mapper.LastlegOrderMapper.class,com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class,ExceptionReturnItemMapper.class,ReturnCartonStockReceiptMapper.class,
|
||||
ReturnInventoryCartonMapper.class,ReturnInventoryCartonItemMapper.class,ReturnInventoryBalanceMapper.class,ReturnInventoryMovementMapper.class,
|
||||
DeliveryOrderMapper.class,OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaPickMapper.class,OutboundPdaTaskMapper.class,DispatchAggregateMapper.class,DispatchReadMapper.class,DispatchItemMapper.class,DispatchFeeCargoMapper.class,
|
||||
DispatchPdaTaskMapper.class,DispatchPdaPickMapper.class,DispatchPdaPickOrderMapper.class,DeliveryOperationMapper.class);
|
||||
@@ -165,6 +165,75 @@ class ReturnCartonReturnPersistenceTest {
|
||||
assertThatThrownBy(()->tx.execute(t->business.markReturn("C","OP","操作员","DL-1",foreign))).hasMessageContaining("主单不属于该派送单");
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero();
|
||||
}
|
||||
/** 同派送两张原单可一起退回,部分签收数量保留,同键重放不增异常。 */
|
||||
@Test void multipleMastersOfOneDeliveryFreezeTogetherAndReplayWithoutDuplicateExceptions() {
|
||||
var requests=prepareTwoMasterReturns();var business=business();
|
||||
var created=tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests));
|
||||
assertThat(created).hasSize(2);
|
||||
assertThat(db.queryForList("SELECT last_mile_order_id FROM ll_exception_order ORDER BY last_mile_order_id",Long.class)).containsExactly(100L,101L);
|
||||
assertThat(db.queryForObject("SELECT SUM(approved_return_packages) FROM ll_exception_return_item",Long.class)).isEqualTo(2);
|
||||
assertThat(db.queryForObject("SELECT signed_packages FROM ll_delivery_order WHERE id=3",Long.class)).isEqualTo(2);
|
||||
var replay=tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests));
|
||||
assertThat(replay).allSatisfy(row->assertThat(row.replayed()).isTrue());
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isEqualTo(2);
|
||||
}
|
||||
/** 从排车适配器传入两张原单,真实服务分别建异常;各来源仍保持独立。 */
|
||||
@Test void dispatchAdapterCreatesBothReturnsThroughRealService() {
|
||||
var requests=prepareTwoMasterReturns();var business=business();
|
||||
var repository=mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class);
|
||||
when(repository.deliveries(10L)).thenReturn(List.of(new com.baiyue.lastleg.exceptionorder.mapper.ExceptionWorkflowMapper.Delivery("C","DL-1")));
|
||||
var adapter=new DispatchExceptionAdapter(repository,business);
|
||||
var selections=requests.stream().map(r->new com.baiyue.lastleg.dispatch.port.DispatchExceptionPort.Selection("DL-1",r.lastMileOrderId(),
|
||||
r.items().stream().map(i->new com.baiyue.lastleg.dispatch.port.DispatchExceptionPort.Item(i.outboundPalletItemId(),i.inventoryPositionId(),i.returnCartonId(),i.packageCount())).toList())).toList();
|
||||
var command=new com.baiyue.lastleg.dispatch.port.DispatchCommands.MarkException(0L,"adapter-multiple",com.baiyue.lastleg.dispatch.port.DispatchTypes.ExceptionType.PARTIAL_RECEIPT,
|
||||
com.baiyue.lastleg.dispatch.port.DispatchTypes.ReturnDirection.OVERSEAS_WAREHOUSE,"多单部分签收",List.of(),selections);
|
||||
tx.executeWithoutResult(t->adapter.register(10L,"OP",command));
|
||||
assertThat(db.queryForList("SELECT last_mile_order_id FROM ll_exception_order ORDER BY last_mile_order_id",Long.class)).containsExactly(100L,101L);
|
||||
assertThat(db.queryForObject("SELECT SUM(approved_return_packages) FROM ll_exception_return_item",Long.class)).isEqualTo(2);
|
||||
}
|
||||
/** 多单退回可按任意顺序取消;最后一单取消才恢复批次前状态,签收历史保持不变。 */
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans={false,true})
|
||||
void cancellingBothMastersRestoresOriginalStateInEitherOrder(boolean reversed) {
|
||||
var requests=prepareTwoMasterReturns();var business=business();
|
||||
var created=new ArrayList<>(tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests)));
|
||||
if(reversed)Collections.reverse(created);
|
||||
for(int index=0;index<created.size();index++) {
|
||||
var row=created.get(index);
|
||||
tx.executeWithoutResult(t->business.cancel("C","OP",row.exceptionNo(),new ExceptionOrderRequests.Cancel(0,"cancel-"+row.id(),"取消本单退回")));
|
||||
assertThat(db.queryForObject("SELECT status FROM ll_delivery_order WHERE id=3",String.class)).isEqualTo(index==0?"RETURN_PENDING":"DELIVERING");
|
||||
}
|
||||
assertThat(db.queryForObject("SELECT signed_packages FROM ll_delivery_order WHERE id=3",Long.class)).isEqualTo(2);
|
||||
assertThat(db.queryForList("SELECT abnormal FROM ll_order",String.class)).doesNotContain("true");
|
||||
}
|
||||
/** 第二单借用第一单原箱时,第一单异常和冻结数量也必须回滚。 */
|
||||
@Test void invalidSecondMasterRollsBackTheWholeMultiOrderReturn() {
|
||||
var requests=prepareTwoMasterReturns();var business=business();
|
||||
var wrong=new ExceptionOrderRequests.MarkReturn(101L,"原因","OVERSEAS_WAREHOUSE",null,requests.get(0).items(),"batch-wrong");
|
||||
assertThatThrownBy(()->tx.execute(t->business.markReturns("C","OP","操作员","DL-1",List.of(requests.get(0),wrong))))
|
||||
.hasMessageContaining("不属于该派送单和尾程主单");
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero();
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_return_item",Long.class)).isZero();
|
||||
assertThat(db.queryForObject("SELECT status FROM ll_delivery_order WHERE id=3",String.class)).isEqualTo("DELIVERING");
|
||||
}
|
||||
/** 单笔数量均合法也不能让合计退回超过部分签收后的未签数量。 */
|
||||
@Test void multiOrderTotalCannotIncludeAlreadySignedPackages() {
|
||||
var requests=prepareTwoMasterReturns();var business=business();
|
||||
db.update("UPDATE ll_delivery_order SET signed_packages=3 WHERE id=3");
|
||||
assertThatThrownBy(()->tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests))).hasMessageContaining("退回总箱数");
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero();
|
||||
}
|
||||
/** 两张原单共享派送单,但各自只拥有一只已实际出库的原箱。 */
|
||||
private List<ExceptionOrderRequests.MarkReturn> prepareTwoMasterReturns() {
|
||||
prepareMarkReturn();
|
||||
db.update("UPDATE ll_delivery_order SET status='DELIVERING',package_count=4,signed_packages=2 WHERE id=3");
|
||||
db.update("INSERT INTO ll_order(id,customer_id,order_no,delivery_order_no,outbound_order_no,overseas_warehouse_id,package_count) VALUES(101,'C','LM-2','DL-1','OUT-1','92',1)");
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,return_carton_id,packages,product_quantity) VALUES(104,'C',102,4,102,1,1)");
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,packages) VALUES(106,'C',104,101,1)");
|
||||
db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id,carton_mark,status,total_quantity) VALUES(102,'C','92','BOX-2','OUTBOUNDED',1)");
|
||||
return List.of(new ExceptionOrderRequests.MarkReturn(100L,"多单部分签收","OVERSEAS_WAREHOUSE",null,List.of(new ExceptionOrderRequests.ReturnItem(5L,null,1,101L)),"batch-master-1"),
|
||||
new ExceptionOrderRequests.MarkReturn(101L,"多单部分签收","OVERSEAS_WAREHOUSE",null,List.of(new ExceptionOrderRequests.ReturnItem(5L,null,1,102L)),"batch-master-2"));
|
||||
}
|
||||
/** 将真实已出库换标主单置为拒签,不添加普通入仓关系。 */
|
||||
private void prepareMarkReturn() {
|
||||
db.update("DELETE FROM ll_exception_return_item");db.update("DELETE FROM ll_exception_order");
|
||||
@@ -174,7 +243,8 @@ class ReturnCartonReturnPersistenceTest {
|
||||
/** 使用真实Mapper组装异常业务服务,资源和文件网关维持隔离。 */
|
||||
private ExceptionOrderService business() {
|
||||
var business=new ExceptionOrderService();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(business,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(business,"workflow",new com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository(
|
||||
map(ExceptionWorkflowMapper.class),map(ExceptionOrderMapper.class),map(com.baiyue.lastleg.mapper.LastlegOrderMapper.class)));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(business,"exceptionOrders",map(ExceptionOrderMapper.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(business,"returnItems",map(ExceptionReturnItemMapper.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(business,"commands",map(ExceptionOrderCommandMapper.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);
|
||||
|
||||
+22
@@ -18,6 +18,28 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
/** 同筛选完整统计、旧单不回填和权限隔离的直接回归。 */
|
||||
class OwnedBusinessQueryTest {
|
||||
/** 自有、外配共享散板订单明细,跨客户同号不合并,目的地和托盘都不丢失。 */
|
||||
@Test void detailsGroupLooseboardSourcesUsingDispatchOrderAuthorityInBothScopes() {
|
||||
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();var dispatch=mock(DispatchDetailReader.class);
|
||||
var original=mock(com.baiyue.lastleg.dispatch.port.DispatchViews.Detail.class);
|
||||
var first=OwnedBusinessFixtures.cargo().get(0);var second=OwnedBusinessFixtures.cargo().get(1);var other=OwnedBusinessFixtures.cargo().get(0);
|
||||
second.setCustomerId(first.getCustomerId());second.setWarehousePalletId(32L);second.setPalletNo("PAL-32");second.setDestinationWarehouseCode("LAX9");second.setPackageCount(8);
|
||||
other.setCustomerId("102");other.setOrderNo("LB-SAME-NUMBER");
|
||||
when(repo.cargo(any())).thenReturn(List.of(first,second,other));when(repo.batches(any())).thenReturn(List.of());
|
||||
when(dispatch.detail(any(),any())).thenReturn(original);
|
||||
when(original.orderDetails()).thenReturn(List.of(new com.baiyue.lastleg.dispatch.port.DispatchViews.OrderDetail("101","客户A","LB1",null,null,2,null,null,null,first.getSourceKey(),"LOCAL_LOOSEBOARD","LB1",List.of(first.getSourceKey(),second.getSourceKey())),
|
||||
new com.baiyue.lastleg.dispatch.port.DispatchViews.OrderDetail("102","客户B","LB1",null,null,1,null,null,null,other.getSourceKey(),"LOCAL_LOOSEBOARD","LB1",List.of(other.getSourceKey()))));
|
||||
var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,dispatch);
|
||||
for(var scope:List.of(com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope.OWNED,com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope.OUTSOURCED)) {
|
||||
var source=OwnedBusinessFixtures.source();source.setPooledCargo(scope.isPooledCargo());
|
||||
var result=service.detailForWrite(OwnedBusinessFixtures.actor(),source,scope);
|
||||
assertEquals(2,result.cargo().size());var order=result.cargo().get(0);
|
||||
assertEquals("LB1",order.orderNo());assertNull(order.inboundCode());assertEquals("9",order.packages());
|
||||
assertEquals("PAL-31、PAL-32",order.palletNo());assertEquals(List.of("FBA:ONT8","FBA:LAX9"),order.destinationKeys());
|
||||
assertEquals("102",result.cargo().get(1).customerId());assertEquals("LB1",result.cargo().get(1).orderNo());
|
||||
}
|
||||
assertEquals("ORDER-IN-A",first.getOrderNo());verify(repo,never()).update(any(),anyLong());
|
||||
}
|
||||
/** 当前页只有一车时统计仍来自全量,日期边界转换为UTC且共用过滤。 */
|
||||
@Test void summaryDoesNotUseCurrentPageLengthAndDateUsesPlannedDeparture() {
|
||||
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();
|
||||
|
||||
+11
-2
@@ -32,7 +32,7 @@ class InboundPlanDispatchStatusPersistenceTest {
|
||||
db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))");
|
||||
db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))");
|
||||
db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,status VARCHAR(30),stage VARCHAR(30))");
|
||||
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,status VARCHAR(30),stage VARCHAR(30),task_type VARCHAR(30) DEFAULT 'DELIVERY')");
|
||||
db.update("INSERT INTO ll_inbound_plan VALUES(1,'LIP-1','A',90,TRUE,FALSE,FALSE,'RECEIVED',CURRENT_TIMESTAMP,0)");
|
||||
db.update("INSERT INTO ll_inbound_plan_line VALUES(2,1,3,'IN-1','91','ABE4','ABE4','ABE4',1,1,1)");
|
||||
db.update("INSERT INTO ll_order VALUES(3,'A','LX-1','FIRST_LEG','LA','洛杉矶仓','客户A')");
|
||||
@@ -64,6 +64,15 @@ class InboundPlanDispatchStatusPersistenceTest {
|
||||
assertStatus("UNSCHEDULED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pickupTripDoesNotCountAsOutboundScheduling() {
|
||||
dispatch(10, "ACTIVE", "DELIVERED", "A");
|
||||
db.update("UPDATE ll_dispatch_order SET task_type='PICKUP'");
|
||||
assertStatus("UNSCHEDULED");
|
||||
dispatch(11, "ACTIVE", "BEFORE_LOADING", "A");
|
||||
assertStatus("SCHEDULED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void departedAndCompletedDispatchesRemainScheduled() {
|
||||
dispatch(10, "ACTIVE", "DELIVERING", "A");
|
||||
@@ -110,7 +119,7 @@ class InboundPlanDispatchStatusPersistenceTest {
|
||||
}
|
||||
|
||||
private void dispatch(long id, String status, String stage, String itemOwner) {
|
||||
db.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, status, stage);
|
||||
db.update("INSERT INTO ll_dispatch_order(id,status,stage) VALUES(?,?,?)", id, status, stage);
|
||||
db.update("INSERT INTO ll_dispatch_item VALUES(?,?,?,3)", id, itemOwner, id);
|
||||
}
|
||||
|
||||
|
||||
+16
@@ -47,13 +47,16 @@ class LocalLooseboardServiceTest {
|
||||
@Mock LocalLooseboardOperationMapper operations;
|
||||
@Mock LastlegOrderMapper lastlegOrders;
|
||||
@Mock LastlegOrderStatusHistoryMapper lastlegHistories;
|
||||
@Mock com.baiyue.lastleg.looseboard.port.LooseboardDispatches dispatches;
|
||||
@Spy LastlegOrderNoGenerator lastlegOrderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders();
|
||||
private LocalLooseboardService service;
|
||||
private final Map<String, LocalLooseboardOperation> operationRows = new HashMap<>();
|
||||
|
||||
/** 注入真实仓储组合及客户隔离的排车查询端口。 */
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new LocalLooseboardService();
|
||||
ReflectionTestUtils.setField(service, "dispatches", dispatches);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
|
||||
var repository = new LocalLooseboardRepository();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
@@ -77,6 +80,19 @@ class LocalLooseboardServiceTest {
|
||||
});
|
||||
}
|
||||
|
||||
/** 无车队预订的普通散板也返回全部排车,客户和原单必须原样限定。 */
|
||||
@Test
|
||||
void detailIncludesAllDispatchesWithoutFleetBooking() {
|
||||
when(orders.findNo("100", "LB1")).thenReturn(order(LocalLooseboardService.IN_TRANSIT, 1L));
|
||||
when(dispatches.looseboardDispatches("100", "LB1")).thenReturn(List.of("PC1", "PC2"));
|
||||
|
||||
var detail = service.detail("100", "LB1");
|
||||
|
||||
assertEquals(List.of("PC1", "PC2"), detail.dispatchNos());
|
||||
org.junit.jupiter.api.Assertions.assertNull(detail.summary().fleetArrangement());
|
||||
verify(dispatches).looseboardDispatches("100", "LB1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createIsTenantScopedIdempotentAndStartsPendingAcceptance() {
|
||||
AtomicReference<LocalLooseboardOrder> saved = new AtomicReference<>();
|
||||
|
||||
+12
@@ -53,6 +53,18 @@ class LastlegOrderSailingStageTest {
|
||||
assertEquals(List.of(3L, 4L, 7L, 8L, 9L), find(db, null, SailingStage.AFTER_DEPARTURE, null));
|
||||
assertEquals(List.of(3L, 4L, 7L, 9L), find(db, "A", SailingStage.AFTER_DEPARTURE, null));
|
||||
assertEquals(List.of(3L, 7L, 9L), find(db, "A", SailingStage.AFTER_DEPARTURE, "PENDING_ASSIGNMENT"));
|
||||
// 去重仓储执行真实Mapper,沿用客户隔离和人工覆盖后的实际开船事实。
|
||||
sql.execute("ALTER TABLE ll_order ADD source_order_no VARCHAR(64)");
|
||||
sql.execute("UPDATE ll_order SET source_order_no=CONCAT('SOURCE-',id)");
|
||||
var config = new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
|
||||
config.addMapper(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);
|
||||
var factory = new com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:sailing;MODE=MySQL", "", ""));
|
||||
factory.setConfiguration(config);
|
||||
var mapper = new org.mybatis.spring.SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);
|
||||
assertEquals(java.util.Set.of("A:SOURCE-3", "A:SOURCE-4", "A:SOURCE-7", "A:SOURCE-9"),
|
||||
new com.baiyue.lastleg.repository.PreDepartureOrderRepository(mapper).departedKeys("A"));
|
||||
|
||||
assertEquals(java.util.stream.LongStream.rangeClosed(1, 14).boxed().toList(), find(db, null, null, null)); // Discard applications have their own workflow and are excluded.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ class LastlegOrderServiceTest {
|
||||
@Mock WarehouseResourceGateway warehouseResourceGateway;
|
||||
@Mock ContainerShippingGateMapper containerShippingGates;
|
||||
@Mock OrderPreDepartureClient preDeparture;
|
||||
@Mock com.baiyue.lastleg.repository.PreDepartureOrderRepository preDepartureOrders;
|
||||
@Mock ReturnOrderReferenceResolver returnOrderReferences;
|
||||
@Mock com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService returnReceipts;
|
||||
LastlegOrderService service;
|
||||
@@ -118,6 +119,27 @@ class LastlegOrderServiceTest {
|
||||
assertEquals(new java.math.BigDecimal("22"), summary.netWeightKg());
|
||||
}
|
||||
|
||||
/** 已清关来源即使订单服务仍返回开船前记录,也应先去重再分页。 */
|
||||
@Test void excludesDepartedSourcesBeforePagination() {
|
||||
when(preDepartureOrders.departedKeys("101")).thenReturn(java.util.Set.of("101:EXXHK2611050001"));
|
||||
var departed = preDepartureRow(1L, "EXXHK2611050001");
|
||||
var waiting = preDepartureRow(2L, "WAITING-2");
|
||||
when(preDeparture.page(any(), eq(101L), any(), any(), eq(1), eq(2000)))
|
||||
.thenReturn(ApiResponse.success(new PageResult<>(List.of(departed, waiting), 1, 2000, 2, 1)));
|
||||
var query = new LastlegOrderRequests.Query(1L, 1L, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null,
|
||||
LastlegOrderRequests.SailingStage.BEFORE_DEPARTURE, null, null, null);
|
||||
var page = service.page("101", query);
|
||||
assertEquals(1L, page.total());
|
||||
assertEquals(1L, page.pages());
|
||||
assertEquals("WAITING-2", page.records().get(0).sourceOrderNo());
|
||||
}
|
||||
|
||||
private OrderPreDepartureClient.Row preDepartureRow(long id, String no) {
|
||||
return new OrderPreDepartureClient.Row(id,101L,no,"IN-"+id,"WAIT_SPLIT", "客户",null,null,null,
|
||||
null,2,null,null,null,null,null,null,null,null,null,null);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
|
||||
@@ -144,6 +166,7 @@ class LastlegOrderServiceTest {
|
||||
ReflectionTestUtils.setField(service, "warehouseResourceGateway", warehouseResourceGateway);
|
||||
ReflectionTestUtils.setField(service, "containerShippingGates", containerShippingGates);
|
||||
ReflectionTestUtils.setField(service, "orderPreDepartureClient", preDeparture);
|
||||
ReflectionTestUtils.setField(service, "preDepartureOrders", preDepartureOrders);
|
||||
lenient().when(preDeparture.page(any(), any(), any(), any(), anyInt(), anyInt()))
|
||||
.thenReturn(ApiResponse.success(new PageResult<>(List.of(), 1, 2000, 0, 0)));
|
||||
}
|
||||
|
||||
@@ -647,3 +647,4 @@ Admin 全量中的 `phase4-cross-region-events` 使用内部 `test-event-fault-e
|
||||
固定角色/账号统一配置:TEST 使用显式环境文件中的 `ADMIN_TEST_FIXED_ACTORS` JSON 对象,GRAY 通用入口使用 `ADMIN_GRAY_FIXED_ACTORS`,GRAY 拼车和页面样本使用 `CHONGCHONG_ACCEPTANCE_FIXED_ACTORS`,不跨环境借用。槽位和字段见模块索引 `fixed-test-actors.mjs`;密码仅留在私有环境文件及明确的私有页面交接文件中。GRAY PDA 账号通过 `CHONGCHONG_ACCEPTANCE_PDA_ACTORS` 传递到专用入口(通用入口为 `ADMIN_GRAY_PDA_ACTORS`);只读核验并复用已有仓绑定账号。当前 Admin HTTP 客户端拒绝 POST 新增角色、账号、权限和供应商,所有上下文均不例外。角色权限不足、账号停用或供应商缺失均停止,不自动创建、启用、重置或扩大权限。拼车 HTTP 和成本探针完成后仅退出会话;页面固定账号保留原状态,不停用。
|
||||
|
||||
本机 `local-dispatch-transport-acceptance.mjs`、`local-transfer-pickup-acceptance.mjs` 也只复用配置的平台账号和 `LOCAL_TRANSFER_PDA_USERNAME/PASSWORD`,不再补权限、创建操作员角色或PDA账号;权限不足即停止。Client HTTP 客户端同样拒绝新增客户角色、成员与权限。
|
||||
`local-mixed-looseboard-workflow-acceptance.mjs`:仅本机统一 Gateway 的六单混排 HTTP 验收,三单送货到仓分两托实收共九箱、三单异地上门取货共六箱。使用显式开发配置的 `ADMIN_TEST_*` 与 `MIXED_PDA_USERNAME/MIXED_PDA_PASSWORD`(密码可复用显式 `LOCAL_TRANSFER_PDA_PASSWORD`),例如 `node --env-file=<开发配置> --env-file=<PDA配置> scripts/local-mixed-looseboard-workflow-acceptance.mjs`。会写客户测试原单、排车、专用测试车及司机和合成照片;默认报告 `reports/local-mixed-looseboard-workflow.json`,`MIXED_ACCEPTANCE_OUTPUT` 可覆盖,`MIXED_ACCEPTANCE_PHASE=prepare` 停在审核后。默认 `MIXED_ACCEPTANCE_SCENARIO=multi-return` 一次选择同派送两张原单各退一箱,生成退回单、计划并由PDA实收,重放核对库存仅恢复一次,剩余七箱保存签收。异常实签实退核平后调用正式车次结案入口;账号缺少结案权限时报告 `pendingAction`,不得声称结案或资源已释放。`MIXED_ACCEPTANCE_SCENARIO=normal` 配合新的独立报告路径,验证全部九箱正常签收及自动结案释放。回仓通过正式资源接口创建两张本轮托盘并绑定既有有效库位,不复用已报废的原出库托。只复用既有客户/PDA权限,不直接写数据库,不删除数据,续跑沿报告原单和幂等键恢复,未知写结果停写。HTTP 通过不代表 PDA 真机扫码、实物测量或真实凭证验收。
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { prepareFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
|
||||
|
||||
const gateway = process.env.ADMIN_TEST_GATEWAY?.replace(/\/$/, '')
|
||||
if (!gateway || !['localhost', '127.0.0.1'].includes(new URL(gateway).hostname)) throw Error('必须显式指定本机 Gateway')
|
||||
const output = resolve(process.env.MIXED_ACCEPTANCE_OUTPUT || 'reports/local-mixed-looseboard-workflow.json')
|
||||
const scenario = process.env.MIXED_ACCEPTANCE_SCENARIO || 'multi-return'
|
||||
if (!['multi-return', 'normal'].includes(scenario)) throw Error('验收场景仅支持 multi-return 或 normal')
|
||||
let report
|
||||
try { report = JSON.parse(await readFile(output, 'utf8')) } catch (error) { if (error.code !== 'ENOENT') throw error }
|
||||
report ||= { marker: `MIXLB-${Date.now()}`, environment: 'LOCAL', syntheticEvidence: true, orders: [], operations: {}, checks: [], createdResources: {} }
|
||||
if (report.scenario && report.scenario !== scenario || !report.scenario && report.orders.length && scenario !== 'multi-return') throw Error('不同场景必须使用独立验收报告')
|
||||
report.scenario = scenario
|
||||
const root = '/api/platform/v1/last-leg', pr = '/api/pda/v1/last-leg'
|
||||
const key = name => createHash('sha256').update(`${report.marker}:${name}`).digest('hex')
|
||||
let admin, pda, merchant
|
||||
/** 原子保存写请求和回执,失败留单续跑,不删除或重复造单。 */
|
||||
async function save() { await mkdir(dirname(output), { recursive: true }); await writeFile(`${output}.tmp`, JSON.stringify(report, null, 2)); await rename(`${output}.tmp`, output) }
|
||||
/** 断言保留业务含义,失败立即停止后续写入。 */
|
||||
function check(value, label) { report.checks.push({ label, passed: Boolean(value) }); if (!value) throw Error(label) }
|
||||
/** 统一Gateway真实HTTP;会话凭据不写入报告。 */
|
||||
async function raw(method, path, body, token = admin) {
|
||||
const form = body instanceof FormData
|
||||
const response = await fetch(gateway + path, { method, signal: AbortSignal.timeout(45000), headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body !== undefined && !form ? { 'Content-Type': 'application/json' } : {}), ...(path.endsWith('/exception-orders/return-inbound-plans') ? { 'Idempotency-Key': key(`return-plan-${body.plan.orderId}`) } : {}) }, body: body === undefined ? undefined : form ? body : JSON.stringify(body) })
|
||||
const value = await response.json()
|
||||
return { http: response.status, ...value, requestId: value.requestId || response.headers.get('x-request-id'), traceId: value.traceId || response.headers.get('x-trace-id') }
|
||||
}
|
||||
/** 仅读显式配置的现有账号,不新建或修改权限。 */
|
||||
async function login(username, password) {
|
||||
if (!username || !password) throw Error('缺少显式验收账号配置')
|
||||
const result = await raw('POST', '/api/auth/login', { loginId: username, password }, '')
|
||||
if (String(result.code) !== '200') throw Error(`登录失败:${result.code} ${result.message}`)
|
||||
return result.data.accessToken
|
||||
}
|
||||
/** 每条命令前读取当前服务端事实,禁止自行推算版本。 */
|
||||
async function get(path, token = admin) {
|
||||
const result = await raw('GET', path, undefined, token)
|
||||
if (result.http !== 200 || String(result.code) !== '200') throw Error(`GET ${path}: ${result.http}/${result.code} ${result.message}`)
|
||||
return result.data
|
||||
}
|
||||
/** 原步骤成功回执可恢复;未知写结果停写,保留原幂等键供核对。 */
|
||||
async function write(name, method, path, body, token = admin) {
|
||||
if (report.operations[name]?.status === 'SUCCESS') return report.operations[name].result
|
||||
if (report.unknownWrite) throw Error(`上次写结果未知:${report.unknownWrite}`)
|
||||
const entry = { method, path, body: body instanceof FormData ? 'SYNTHETIC_TEST_IMAGE' : body, status: 'SENDING' }
|
||||
report.operations[name] = entry; await save()
|
||||
let result
|
||||
try { result = await raw(method, path, body, token) } catch (error) { entry.status = 'UNKNOWN'; report.unknownWrite = name; await save(); throw error }
|
||||
Object.assign(entry, { http: result.http, code: result.code, requestId: result.requestId, traceId: result.traceId, message: result.message })
|
||||
entry.status = result.http === 200 && String(result.code) === '200' ? 'SUCCESS' : result.http >= 500 || result.code == null ? 'UNKNOWN' : 'FAILED'
|
||||
if (entry.status === 'UNKNOWN') report.unknownWrite = name
|
||||
if (entry.status === 'SUCCESS') entry.result = result.data
|
||||
await save()
|
||||
if (entry.status !== 'SUCCESS') throw Error(`${method} ${path}: ${result.http}/${result.code} ${result.message}`)
|
||||
return result.data
|
||||
}
|
||||
/** 合成小图仅证明文件协议和归属,不能代表现场物流证据。 */
|
||||
async function photo(name, path, token = admin) {
|
||||
const form = new FormData(); form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')], { type: 'image/png' }), `${report.marker}-${name}-SYNTHETIC-TEST.png`)
|
||||
return write(name, 'POST', path, form, token)
|
||||
}
|
||||
/** 三箱分两托实收,订单展示应汇总一行,PDA沿用真实托盘明细。 */
|
||||
async function receive(order) {
|
||||
const path = `${pr}/warehouse-transfers/${order.transferNo}`, label = order.orderNo
|
||||
let d = await get(path, pda)
|
||||
if (d.status === 'READY_FOR_DISPATCH') return
|
||||
while (d.pallets.length < 2) { await write(`${label}-draft-${d.pallets.length}`, 'POST', `${path}/pallets/drafts`, { idempotencyKey: key(`${label}-draft-${d.pallets.length}`) }, pda); d = await get(path, pda) }
|
||||
for (const initial of d.pallets) {
|
||||
let p = d.pallets.find(row => row.id === initial.id)
|
||||
if (p.status === 'DRAFT') await write(`${label}-generate-${p.id}`, 'POST', `${path}/pallets/${p.id}/generate`, { idempotencyKey: key(`${label}-generate-${p.id}`) }, pda)
|
||||
d = await get(path, pda); p = d.pallets.find(row => row.id === initial.id)
|
||||
if (p.status === 'GENERATED') await write(`${label}-bind-${p.id}`, 'POST', `${path}/pallets/${p.id}/bind`, { idempotencyKey: key(`${label}-bind-${p.id}`), palletCode: p.palletNo }, pda)
|
||||
d = await get(path, pda)
|
||||
}
|
||||
if (!d.cartons.every(row => row.countedAt)) await write(`${label}-count`, 'POST', `${path}/cartons/count`, { idempotencyKey: key(`${label}-count`), forecastPackageCount: d.packageCount,
|
||||
boxes: d.cartons.map((row, i) => ({ cartonMark: row.cartonMark, transferPalletId: d.pallets[i === 0 ? 0 : 1].id, length: 40, width: 30, height: 25, weight: 4, items: [{ skuCode: d.items[0].skuCode, quantity: 1 }] })) }, pda)
|
||||
const f = await photo(`${label}-measurement`, `${path}/measurement-photos/files`, pda)
|
||||
await write(`${label}-palletize`, 'POST', `${path}/palletize`, { idempotencyKey: key(`${label}-palletize`), photoFileIds: [f.fileId] }, pda)
|
||||
d = await get(path, pda); check(d.status === 'READY_FOR_DISPATCH' && d.pallets.length === 2, `${label}三箱两托实收完成`); order.receiving = d; await save()
|
||||
}
|
||||
/** 六张本轮原单:三单到仓实收,三单保留异地上门取货。 */
|
||||
async function prepare() {
|
||||
const customers = (await get('/api/platform/v1/iam/tenants?page=1&size=200')).records
|
||||
report.customerId ||= customers.find(row => row.status === 'ACTIVE' && /测试/.test(row.tenantName || row.name))?.id
|
||||
report.warehouseId ||= (await get('/api/platform/v1/warehouse-admin?page=1&size=100&warehouseType=OVERSEAS&status=ACTIVE')).records.find(row => row.warehouseCode === 'US-LAX-01')?.id
|
||||
check(report.customerId && report.warehouseId, '复用有效测试客户和洛杉矶仓'); await save()
|
||||
const ticket = await raw('POST', `/api/platform/v1/iam/tenants/${report.customerId}/impersonation`, { reason: `${report.marker}本机混排验收` })
|
||||
check(String(ticket.code) === '200', '获取本轮客户下单会话')
|
||||
merchant = (await raw('POST', '/api/auth/impersonations/exchange', { code: ticket.data.code }, '')).data.accessToken
|
||||
for (const mode of ['DELIVERY_TO_WAREHOUSE', 'WAREHOUSE_PICKUP']) for (let i = 0; i < 3; i++) {
|
||||
const label = `${mode}-${i}`, remote = mode === 'WAREHOUSE_PICKUP'
|
||||
let order = report.orders.find(row => row.label === label)
|
||||
if (!order) {
|
||||
const created = await write(label, 'POST', '/api/merchant/v1/last-leg/local-looseboard-orders', { operatingWarehouseId: report.warehouseId, destinationType: 'PRIVATE', destinationPostalCode: '91761', deliveryMode: mode,
|
||||
deliveryContactName: '合成验收联系人', deliveryContactPhone: '0000000000', estimatedArrivalAt: '2026-11-06T09:00:00', trackingNo: `${report.marker}-${label}`,
|
||||
origin: 'Ontario TEST', originAddress: `100 TEST Pickup Road ${i}`, destination: 'Ontario TEST', destinationAddress: '200 TEST Destination Road', productName: `${report.marker}-${label}`,
|
||||
estimatedPalletCount: 2, packageCount: remote ? 2 : 3, totalWeightKg: remote ? 8 : 12, totalVolumeCbm: remote ? 0.06 : 0.09, budgetAmount: 25, currency: 'USD',
|
||||
originContactName: '合成验收取货员', originContactPhone: '0000000000', destinationContactName: '合成验收收货员', destinationContactPhone: '0000000000',
|
||||
pickupAt: '2026-11-06T09:00:00', deliveryAt: '2026-11-07T09:00:00', remark: `${report.marker} SYNTHETIC TEST`, idempotencyKey: key(label) }, merchant)
|
||||
order = { label, mode, orderNo: created.summary.orderNo }; report.orders.push(order); await save()
|
||||
}
|
||||
const path = `${root}/local-looseboard-orders/${order.orderNo}`, query = `?customerId=${report.customerId}`
|
||||
let d = await get(path + query)
|
||||
if (d.summary.status === 'PENDING_ACCEPTANCE') await write(`${label}-review`, 'POST', `${path}/review${query}`, { version: d.summary.version, decision: 'APPROVE', idempotencyKey: key(`${label}-review`), remark: report.marker })
|
||||
d = await get(path + query); order.transferNo = d.summary.transferNo; await save(); check(order.transferNo, `${order.orderNo}审核生成真实履约来源`)
|
||||
if (!remote && !order.receiving) await receive(order)
|
||||
}
|
||||
if (!report.dispatchNo) { report.dispatchNo = (await write('create-dispatch', 'POST', `${root}/dispatches`, { idempotencyKey: key('create-dispatch'), warehouseId: report.warehouseId, publishLooseboardDemand: true, cargo: [], batchAllocations: [], remark: `${report.marker} 仓内九箱+异地六箱` })).dispatchNo; await save() }
|
||||
const path = `${root}/dispatches/${report.dispatchNo}`, cp = `${root}/dispatches/capacity-demands/${report.dispatchNo}/cargo`
|
||||
if (!report.operations['add-cargo']?.result) {
|
||||
const selected = (await get(`${cp}?page=1&size=50`)).candidates.filter(row => report.orders.some(order => row.inboundCode === order.transferNo || row.orderNo === order.orderNo || row.sourceBusinessNo === order.orderNo))
|
||||
check(selected.length >= 6 && selected.every(row => !row.blockReasons.length), '六单均为正式可选来源')
|
||||
const sources = new Map(selected.flatMap(row => row.selectionGroup.sources).map(row => [row.sourceKey, row.selection]))
|
||||
await write('add-cargo', 'POST', cp, { recordVersion: (await get(path)).recordVersion, idempotencyKey: key('add-cargo'), cargo: [...sources.values()], batchAllocations: [] })
|
||||
}
|
||||
report.preparedDetail = await get(path); await save()
|
||||
check(Number(report.preparedDetail.counts.packageCount) === 15, '混排十五箱:仓内九箱、异地六箱')
|
||||
check(report.preparedDetail.orderDetails.length === 6, '关联订单按六张散板原单汇总')
|
||||
if (!report.fleet) {
|
||||
const context = { marker: report.marker, options: {}, createdResources: report.createdResources, checkpoint: save,
|
||||
request: async (method, url, body, token) => { const r = await raw(method, url, body, token || admin); return { http: r.http, body: r } },
|
||||
requestMultipart: async (method, url, file) => { const f = new FormData(); f.append('file', new Blob([file.content], { type: file.type }), file.name); const r = await raw(method, url, f); return { http: r.http, body: r } },
|
||||
apiSuccess: (module, label, response) => { check(response.http === 200 && String(response.body.code) === '200', `${label}: ${response.body.message}`); return response.body.data }, assert: (module, label, response, passed) => check(passed, label) }
|
||||
report.fleet = await prepareFleetTrip(context, 'local-mixed-looseboard', report.dispatchNo, report.warehouseId); await save()
|
||||
}
|
||||
}
|
||||
/** PDA只拣仓内九箱;异地六箱不进入拣货校验和仓内出库。 */
|
||||
async function pickAndLoad() {
|
||||
const path = `${pr}/dispatches/${report.dispatchNo}`
|
||||
let task = await get(path, pda); report.pdaBefore ||= task; await save()
|
||||
check(Number(task.counts.packageCount) === 9 && task.items.every(row => row.positions?.length), 'PDA只要求仓内九箱')
|
||||
for (const initial of task.items) {
|
||||
task = await get(path, pda); let item = task.items.find(row => row.dispatchItemId === initial.dispatchItemId)
|
||||
for (const position of item.positions) {
|
||||
const packages = Math.min(Number(position.availablePackages), Number(item.scheduledPackages) - Number(item.pickedPackages))
|
||||
if (packages <= 0) continue
|
||||
const name = `pick-${item.dispatchItemId}-${position.positionId}`
|
||||
const prep = await write(`${name}-rent`, 'POST', `${pr}/storage-rent/preparations`, { mode: 'DISPATCH', businessNo: report.dispatchNo, itemId: item.dispatchItemId, positionId: position.positionId, packages, productQuantity: packages, idempotencyKey: key(name) }, pda)
|
||||
check(prep.ready === true, '本轮新入库货物仓租预校验就绪')
|
||||
await write(name, 'POST', `${path}/items/${item.dispatchItemId}/pick`, { recordVersion: task.recordVersion, positionId: position.positionId, packages, productQuantity: packages, idempotencyKey: key(name), remark: report.marker }, pda)
|
||||
task = await get(path, pda); item = task.items.find(row => row.dispatchItemId === initial.dispatchItemId)
|
||||
}
|
||||
}
|
||||
task = await get(path, pda); report.pdaPicked = task; await save(); check(task.pickingReconciled && Number(task.counts.pickedPackageCount) === 9, '九箱实际拣货核平')
|
||||
if (task.stage === 'LOADING') { const loading = await get(`${path}/loading`, pda); await write('loading-complete', 'POST', `${path}/loading-complete`, { recordVersion: loading.recordVersion, idempotencyKey: key('loading-complete'), manifestHash: loading.manifestHash, remark: report.marker }, pda) }
|
||||
report.afterLoading = await get(`${root}/dispatches/${report.dispatchNo}`); await save(); check(report.afterLoading.stage === 'DELIVERING', '仓内装车完成立即发运,不等待异地取货')
|
||||
}
|
||||
/** 上门取货三单各存一组取货/送达照片,分托不增加资料块。 */
|
||||
async function remoteDocuments() {
|
||||
const path = `${root}/dispatches/${report.dispatchNo}`
|
||||
let docs = await get(`${path}/loading-documents`)
|
||||
for (const slot of ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT']) {
|
||||
if (docs.photos.some(row => row.slot === slot)) continue
|
||||
const name = `loading-photo-${slot}`, uploadKey = key(`${name}-upload`)
|
||||
const f = await photo(name, `${path}/loading-documents/${slot}/uploads?reviewRound=${docs.reviewRound}&idempotencyKey=${uploadKey}`)
|
||||
await write(`${name}-bind`, 'PUT', `${path}/loading-documents/${slot}`, { recordVersion: docs.recordVersion, idempotencyKey: key(`${name}-bind`), fileId: f.fileId, uploadKey })
|
||||
docs = await get(`${path}/loading-documents`)
|
||||
}
|
||||
report.loadingDocuments = docs; check(docs.complete, '五类装车资料补齐')
|
||||
let execution = await get(`${path}/looseboard-execution`)
|
||||
report.looseboardBefore ||= execution; await save()
|
||||
const items = execution.items.filter(row => row.pickupRequired)
|
||||
check(items.length === 3 && new Set(items.map(row => row.orderNo)).size === 3, '上门取货按三张订单展示资料,六个计划托不拆六块')
|
||||
for (const phase of ['PICKUP', 'ARRIVAL']) {
|
||||
if (phase === 'ARRIVAL') {
|
||||
execution = await get(`${path}/looseboard-execution`)
|
||||
if (!execution.pickupCompleted) await write('pickup-complete', 'POST', `${path}/looseboard-execution/pickup-complete`, { recordVersion: execution.recordVersion, idempotencyKey: key('pickup-complete') })
|
||||
}
|
||||
for (const initial of items) {
|
||||
execution = await get(`${path}/looseboard-execution`)
|
||||
const row = execution.items.find(item => item.itemId === initial.itemId), photos = phase === 'PICKUP' ? row.pickupPhotos : row.arrivalPhotos
|
||||
if (photos.length) continue
|
||||
const name = `${initial.orderNo}-${phase}`, f = await photo(`${name}-upload`, `${path}/looseboard-execution/${initial.itemId}/${phase}/uploads`)
|
||||
await write(`${name}-save`, 'PUT', `${path}/looseboard-execution/${initial.itemId}/${phase}`, { recordVersion: execution.recordVersion, idempotencyKey: key(`${name}-save`), fileIds: [f.fileId] })
|
||||
}
|
||||
}
|
||||
execution = await get(`${path}/looseboard-execution`)
|
||||
if (!execution.pickupCompleted) await write('pickup-complete', 'POST', `${path}/looseboard-execution/pickup-complete`, { recordVersion: execution.recordVersion, idempotencyKey: key('pickup-complete') })
|
||||
report.looseboardAfter = await get(`${path}/looseboard-execution`); await save()
|
||||
check(report.looseboardAfter.pickupCompleted && report.looseboardAfter.items.filter(row => row.pickupRequired).every(row => row.pickupPhotos.length && row.arrivalPhotos.length), '三单上门取货和送达资料全部完成')
|
||||
}
|
||||
/** 一次选择同派送的两张原订单,生成两张异常并沿PDA分批回仓实收。 */
|
||||
async function exceptionReturns() {
|
||||
const path = `${root}/dispatches/${report.dispatchNo}`, query = `?customerId=${report.customerId}`
|
||||
if (!report.operations['multi-order-return']?.result) {
|
||||
const candidates = await get(`${path}/exception-return-options`)
|
||||
report.returnOptions = candidates; await save()
|
||||
const originals = report.orders.filter(row => row.mode === 'DELIVERY_TO_WAREHOUSE').slice(0, 2)
|
||||
const selected = originals.map(order => candidates.find(row => row.lastMileOrderNo === order.transferNo || row.lastMileOrderNo === order.orderNo))
|
||||
check(selected.every(Boolean) && selected[0].deliveryNo === selected[1].deliveryNo, '同一派送单下两张原单均可选择')
|
||||
const returns = selected.map(row => ({ deliveryNo: row.deliveryNo, lastMileOrderId: row.lastMileOrderId,
|
||||
items: [{ outboundPalletItemId: row.sources[0].outboundPalletItemId, inventoryPositionId: row.sources[0].inventoryPositionId,
|
||||
returnCartonId: row.sources[0].returnCartonId, packageCount: 1 }] }))
|
||||
await write('multi-order-return', 'POST', `${path}/exceptions`, { recordVersion: (await get(path)).recordVersion, idempotencyKey: key('multi-order-return'),
|
||||
exceptionType: 'PARTIAL_RECEIPT', returnDirection: 'OVERSEAS_WAREHOUSE', description: `${report.marker} 两单各退一箱,其余签收`, imageFileIds: [], returnSelections: returns })
|
||||
}
|
||||
const rows = (await get(`${root}/exception-orders?page=1&size=100&keyword=${encodeURIComponent(report.marker)}`)).records.filter(row => row.reason?.includes(report.marker))
|
||||
check(rows.length === 2, '一次多选实际生成两张独立异常')
|
||||
report.exceptions ||= []
|
||||
for (const row of rows) {
|
||||
const name = row.exceptionNo, ep = `${root}/exception-orders/${name}`, pp = `${pr}/return-receipts/exceptions/${name}`
|
||||
let detail = await get(ep + query)
|
||||
if (!detail.returnOrderNo) await write(`${name}-generate`, 'POST', `${ep}/generate-return-inbound${query}`, { expectedVersion: detail.order.version, idempotencyKey: key(`${name}-generate`), handlingAction: '合成测试退回海外仓' })
|
||||
detail = await get(ep + query)
|
||||
const tails = (await get(`${root}/orders?page=1&size=50&orderNo=${detail.returnOrderNo}`)).records.filter(item => item.orderNo === detail.returnOrderNo)
|
||||
check(tails.length === 1, `${name}唯一关联退回尾程单`)
|
||||
const tail = tails[0]
|
||||
if (!report.operations[`${name}-plan`]?.result) await write(`${name}-plan`, 'POST', `${root}/exception-orders/return-inbound-plans`, { expectedArrivalAt: '2026-11-06T10:00:00',
|
||||
plan: { customerId: report.customerId, orderId: tail.id, expectedOrderVersion: tail.version, palletCount: 1, palletType: 'STANDARD', assigned: false, mergeRequired: false, urgent: false, remark: report.marker } })
|
||||
if (detail.order.status !== 'COMPLETED') {
|
||||
const tasks = await get(`${pr}/return-receipts/exceptions`, pda); check(tasks.some(task => task.exceptionNo === name), `${name}出现在PDA回仓任务`)
|
||||
const started = await write(`${name}-start`, 'POST', `${pp}/start`, {}, pda)
|
||||
const original = report.orders.find(order => order.transferNo === row.lastMileOrderNo)
|
||||
// 原出库托盘可能已报废;回仓另建实际可用托盘,沿用原仓有效虚拟库位。
|
||||
const sourcePallet = original.receiving.pallets[0]
|
||||
const resourcePath = `/api/platform/v1/overseas-warehouse-resources/${report.warehouseId}`
|
||||
const targetId = await write(`${name}-target-pallet`, 'POST', `${resourcePath}/pallets`, {
|
||||
palletCode: `${report.marker}-${name}`, palletType: 'STANDARD', capacityCbm: 2, unitPrice: 0,
|
||||
status: 'IDLE', locationId: sourcePallet.warehouseLocationId, remark: `${report.marker} 异常回仓验收托盘` })
|
||||
const target = await get(`${resourcePath}/pallets/${targetId}`)
|
||||
check(target.status !== 'SCRAPPED' && target.locationId === sourcePallet.warehouseLocationId, `${name}使用当前仓真实有效托盘`)
|
||||
const f = await photo(`${name}-evidence`, `${pp}/evidence`, pda)
|
||||
await write(`${name}-receive`, 'POST', `${pp}/receive`, { expectedVersion: started.order.version, idempotencyKey: key(`${name}-receive`),
|
||||
palletCode: target.palletCode, locationCode: sourcePallet.warehouseLocationCode, evidenceFileIds: [f.fileId],
|
||||
items: started.returnSources.map(source => ({ outboundPalletItemId: source.outboundPalletItemId, inventoryPositionId: source.inventoryPositionId, returnCartonId: source.returnCartonId, packages: Number(source.maxReturnPackages) })) }, pda)
|
||||
}
|
||||
detail = await get(ep + query)
|
||||
check(detail.order.status === 'COMPLETED' && detail.returnReceipts.reduce((sum, receipt) => sum + Number(receipt.receivedPackages), 0) === 1, `${name}PDA实收一箱且异常完结`)
|
||||
const returnedTail = (await get(`${root}/orders?page=1&size=50&orderNo=${detail.returnOrderNo}`)).records.find(item => item.orderNo === detail.returnOrderNo)
|
||||
check(returnedTail.currentStatus === 'INBOUND_COMPLETED' && Number(returnedTail.receivedPackageCount) === 1, `${name}退回尾程已入库签收`)
|
||||
const receiptCommand = report.operations[`${name}-receive`]
|
||||
const replay = await raw(receiptCommand.method, receiptCommand.path, receiptCommand.body, pda)
|
||||
check(String(replay.code) === '200' && replay.data.replayed, `${name}实收同键重放不重复入账`)
|
||||
const inventoryPath = `${root}/inventory`
|
||||
const ledger = await get(`${inventoryPath}/ledger${query}&size=100&businessType=RETURN_STOCK_IN&businessOrderNo=${name}`)
|
||||
const balances = await get(`${inventoryPath}/balances${query}&size=100&inboundCode=${row.inboundCode}`)
|
||||
check(ledger.records.reduce((sum, entry) => sum + Number(entry.deltaPackages), 0) === 1, `${name}退回库存流水仅增加一箱`)
|
||||
check(balances.records.reduce((sum, balance) => sum + Number(balance.availablePackages), 0) === 1, `${name}原单实际可用库存恢复一箱`)
|
||||
report.inventoryVerification ||= {}; report.inventoryVerification[name] = { ledger, balances, receiptReplay: replay.data }
|
||||
report.exceptions = [...report.exceptions.filter(item => item.order.exceptionNo !== name), detail]; await save()
|
||||
}
|
||||
await finishDelivery(7)
|
||||
}
|
||||
/** 签收只记录剩余实际交付量;异常车核平实签实退后通过正式结案入口释放资源。 */
|
||||
async function finishDelivery(expectedSigned) {
|
||||
const path = `${root}/dispatches/${report.dispatchNo}`, query = `?customerId=${report.customerId}`
|
||||
const delivery = report.afterLoading.documents.deliveries[0], dp = `${root}/delivery-orders/${delivery.deliveryNo}`
|
||||
let documents = await get(`${dp}/documents${query}`)
|
||||
if (!report.operations['delivery-pod-save']?.result) {
|
||||
const f = await photo('delivery-pod', `${dp}/documents/RECEIPT/files${query}`)
|
||||
await write('delivery-pod-save', 'PUT', `${dp}/documents${query}`, { expectedVersion: documents.version, idempotencyKey: key('delivery-pod-save'), bolNo: `${report.marker}-TEST-BOL`,
|
||||
documents: documents.categories.map(category => ({ category: category.category, fileIds: category.category === 'RECEIPT' ? [f.fileId] : category.files.map(file => file.fileId) })) })
|
||||
}
|
||||
report.deliveryFinal = await get(dp + query)
|
||||
check(report.deliveryFinal.order.status === 'DELIVERED' && Number(report.deliveryFinal.order.signedPackages) === expectedSigned, `仓内实际签收${expectedSigned}箱凭签收文件完结`)
|
||||
if (scenario === 'normal') {
|
||||
for (let attempt = 0; attempt < 15; attempt++) {
|
||||
report.finalDispatch = await get(path)
|
||||
if (report.finalDispatch.stage === 'DELIVERED') break
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
}
|
||||
check(report.finalDispatch.stage === 'DELIVERED', '全正常混排自动进入派送完成')
|
||||
}
|
||||
report.finalDispatch = await get(path); report.finalTrip = await get(`${root}/fleet/trips/${report.fleet.tripNo}/completion`); await save()
|
||||
if (scenario === 'multi-return' && report.finalTrip.canComplete) {
|
||||
const previous = report.operations['exception-trip-complete']
|
||||
if (previous?.code !== 'A0403') {
|
||||
try {
|
||||
await write('exception-trip-complete', 'POST', `${root}/fleet/trips/${report.fleet.tripNo}/complete`, {
|
||||
recordVersion: report.finalTrip.recordVersion, idempotencyKey: key('exception-trip-complete'), reason: `${report.marker} 核实七箱签收两箱实际回仓后结案` })
|
||||
} catch (error) { if (report.operations['exception-trip-complete']?.code !== 'A0403') throw error }
|
||||
}
|
||||
}
|
||||
report.tripDetail = await get(`${root}/fleet/trips/${report.fleet.tripNo}`)
|
||||
if (report.operations['exception-trip-complete']?.code === 'A0403') {
|
||||
report.pendingAction = { action: 'EXCEPTION_TRIP_CLOSE', tripNo: report.fleet.tripNo, reason: '当前验收账号缺少platform:fleet:trip:complete,保留原权限及车次事实,待有权限账号结案。' }
|
||||
check(report.finalTrip.canComplete && report.finalTrip.outcomes.every(row => row.remainingPackages === 0), '异常车实签实退核平,可由有权限账号结案')
|
||||
} else check(report.tripDetail.trip.status === 'COMPLETED' && report.tripDetail.trip.vehicleRelease && report.tripDetail.trip.driverRelease, '车次结案并释放车辆及司机')
|
||||
report.harnessCorrections ||= []
|
||||
for (const previous of report.checks.filter(row => row.label === '仓内签收和上门取货送达后整车完结')) {
|
||||
report.harnessCorrections.push({ ...previous, reason: '现有排车规则仅原实发全部正常签收推进DELIVERED;部分签收及实退通过车次正式结案,不能把实退伪作签收。' })
|
||||
}
|
||||
report.checks = report.checks.filter(row => row.label !== '仓内签收和上门取货送达后整车完结')
|
||||
}
|
||||
try {
|
||||
admin = await login(process.env.ADMIN_TEST_USERNAME, process.env.ADMIN_TEST_PASSWORD)
|
||||
pda = await login(process.env.MIXED_PDA_USERNAME, process.env.MIXED_PDA_PASSWORD || process.env.LOCAL_TRANSFER_PDA_PASSWORD)
|
||||
await prepare()
|
||||
if (process.env.MIXED_ACCEPTANCE_PHASE !== 'prepare') {
|
||||
if (!report.afterLoading) await pickAndLoad()
|
||||
await remoteDocuments()
|
||||
if (scenario === 'multi-return') await exceptionReturns()
|
||||
else await finishDelivery(9)
|
||||
}
|
||||
report.phase = process.env.MIXED_ACCEPTANCE_PHASE === 'prepare' ? 'PREPARED' : report.pendingAction ? 'COMPLETED_WITH_PENDING_ACTION' : 'COMPLETED'; delete report.error; await save()
|
||||
console.log(JSON.stringify({ phase: report.phase, dispatchNo: report.dispatchNo, orders: report.orders.map(({ orderNo, mode }) => ({ orderNo, mode })), output }))
|
||||
} catch (error) { report.error = error.message; await save(); console.error(error.message); process.exitCode = 1 }
|
||||
Reference in New Issue
Block a user