Compare commits
23
Commits
51c3132195
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ab5411e53 | ||
|
|
c71a065dda | ||
|
|
e132c3c80a | ||
|
|
f4901d155c | ||
|
|
87bb905283 | ||
|
|
1611c42d28 | ||
|
|
e86bdcd623 | ||
|
|
3cc748974f | ||
|
|
6d8a69b995 | ||
|
|
99f896848e | ||
|
|
08b28c72e7 | ||
|
|
f571e107e4 | ||
|
|
51ea89e399 | ||
|
|
d37522081b | ||
|
|
cc9bcbed95 | ||
|
|
7cd764e14a | ||
|
|
f4362f6dfd | ||
|
|
9c8273d9a0 | ||
|
|
e995a87b0e | ||
|
|
c6bbb22a02 | ||
|
|
d722bcae49 | ||
|
|
00120df2eb | ||
|
|
9af1eba876 |
@@ -125,6 +125,14 @@ pipeline {
|
||||
git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}"
|
||||
git checkout --detach "${RESOLVED_SOURCE_COMMIT}"
|
||||
test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")"
|
||||
# TEST 重发旧提交也须包含限额文件及部署入口,避免重建时恢复无限制容器。
|
||||
if [ "$RESOLVED_ENVIRONMENT" = "test" ]; then
|
||||
if ! git cat-file -e "${RESOLVED_SOURCE_COMMIT}:deploy/docker-compose.test-limits.yml" 2>/dev/null ||
|
||||
! git grep -q -F 'docker-compose.test-limits.yml' "$RESOLVED_SOURCE_COMMIT" -- deploy/scripts/deploy-built-service.sh; then
|
||||
echo "目标 TEST 提交缺少完整内存保护,请选择包含限额修复的 test 提交" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ "$RESOLVED_ENVIRONMENT" = "production" ]; then
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
git merge-base --is-ancestor "$RESOLVED_SOURCE_COMMIT" origin/main || {
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@
|
||||
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;`FINANCE_VOUCHER` 按客户与业务 ID 精确校验;`OVERSEAS_UNLOAD_PHOTO` 是允许同柜多客户的柜级物理证据,固定平台范围 `tenantId=0` 并按接柜会话 ID 精确校验 |
|
||||
| 公司车辆付款凭证 | `src/main/resources/openapi/finance-vehicle-voucher-v1.yaml`;类型`FINANCE_VEHICLE_VOUCHER`,固定平台0和Finance费用ID,仅非空JPG/PNG、10MiB及签名校验;不复用客户凭证或维修照片 |
|
||||
| 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg |
|
||||
| 平台司机驾驶证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-driver-license-v1.yaml`;`FLEET_DRIVER_LICENSE` 固定平台 `tenantId=0`、`businessId=driverId`,仅非空 PDF/JPG/PNG、最大 10MiB并校验签名;Lastleg维护审核、当前/历史附件关联及读取权限 |
|
||||
| 平台司机驾驶证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-driver-license-v1.yaml`;`FLEET_DRIVER_LICENSE` 固定平台 `tenantId=0`、`businessId=driverId`,上传仅非空 JPG/PNG、最大 10MiB并校验签名;Lastleg维护审核、当前/历史附件关联及读取权限 |
|
||||
| 车辆保养加油照片 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-vehicle-record-photo-v1.yaml`;`FLEET_VEHICLE_RECORD_PHOTO` 固定平台0及运维记录ID,仅非空JPG/PNG、最大10MiB并校验签名;Lastleg校验车辆/记录/附件关联与权限,不使用车牌作为文件归属键 |
|
||||
| 出车实测凭证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-trip-operation-evidence-v1.yaml`;`FLEET_TRIP_OPERATION_EVIDENCE`固定平台0及车次ID,PDF/JPG/PNG、10MiB与签名校验;Lastleg管理实测版本引用及读写权限,附件选填 |
|
||||
| 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,Lastleg 校验申请/明细关系、客户归属、权限和业务状态 |
|
||||
|
||||
@@ -9,7 +9,7 @@ import java.util.Arrays;
|
||||
enum BusinessFileCategory {
|
||||
FLEET_VEHICLE_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_DRIVER_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_DRIVER_LICENSE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
|
||||
@@ -5,7 +5,7 @@ info:
|
||||
description: >-
|
||||
复用InternalBusinessFileController,固定平台tenantId=0、businessType=FLEET_DRIVER_LICENSE、
|
||||
businessId=司机内部ID。Lastleg负责司机存在性、列表/上传权限、关联和历史可见性;
|
||||
File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。最大10MiB,仅PDF/JPG/PNG。
|
||||
File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。上传最大10MiB,仅JPG/PNG。
|
||||
上传只生成不可变对象,不自动通过资质审核,不生成公开URL,旧附件通过Lastleg历史引用读取。
|
||||
security:
|
||||
- InternalServiceToken: []
|
||||
|
||||
@@ -24,15 +24,22 @@ class DriverLicenseFileTest {
|
||||
assertThatThrownBy(() -> category.storageBusinessId("0")).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
@Test void storesRealPdfAndRejectsWrongMimeAndSpoofedPdf() throws Exception {
|
||||
/** 驾驶证上传接受有效 JPG/PNG,其他格式和伪装的图片均被拒绝。 */
|
||||
@Test void storesImagesAndRejectsPdfWrongMimeAndSpoofedImages() throws Exception {
|
||||
var storage = storage();
|
||||
var pdf = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7\nlicense".getBytes());
|
||||
var result = storage.store(0, "71", pdf, BusinessFileCategory.FLEET_DRIVER_LICENSE);
|
||||
assertThat(storage.read(result.objectKey())).isEqualTo(pdf.getBytes());
|
||||
for (var format : new String[]{"jpg", "png"}) {
|
||||
var type = format.equals("jpg") ? "image/jpeg" : "image/png";
|
||||
var file = new MockMultipartFile("file", "license." + format, type, image(format));
|
||||
var result = storage.store(0, "71", file, BusinessFileCategory.FLEET_DRIVER_LICENSE);
|
||||
assertThat(storage.read(result.objectKey())).isEqualTo(file.getBytes());
|
||||
}
|
||||
assertThatThrownBy(() -> storage.store(0, "71", pdf, BusinessFileCategory.FLEET_DRIVER_LICENSE))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("仅支持JPG、PNG");
|
||||
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.txt", "text/plain", "text".getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("仅支持");
|
||||
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.pdf", "application/pdf", "not pdf".getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不是有效PDF");
|
||||
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.png", "image/png", pdf.getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("图片");
|
||||
}
|
||||
|
||||
@Test void rejectsMoreThanTenMibBeforeReadingBytes() {
|
||||
@@ -43,6 +50,14 @@ class DriverLicenseFileTest {
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("10MB");
|
||||
}
|
||||
|
||||
/** 生成可解码的图片,覆盖文件格式与实际内容一致的上传路径。 */
|
||||
private static byte[] image(String format) throws java.io.IOException {
|
||||
var bytes = new java.io.ByteArrayOutputStream();
|
||||
var image = new java.awt.image.BufferedImage(1, 1, java.awt.image.BufferedImage.TYPE_INT_RGB);
|
||||
assertThat(javax.imageio.ImageIO.write(image, format, bytes)).isTrue();
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
|
||||
private BusinessFileStorage storage() {
|
||||
var storage = new BusinessFileStorage();
|
||||
ReflectionTestUtils.setField(storage, "configuredRoot", root);
|
||||
|
||||
@@ -27,7 +27,9 @@ public class WorkflowTabService {
|
||||
if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable("费用确认事实不属于本批入仓来源");
|
||||
result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee);
|
||||
}
|
||||
var sources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
|
||||
// 出库单由尾程审核建单触发,不能因国内开船节点尚未补录而跳过事实查询。
|
||||
var sources=facts.stream().map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
|
||||
var requiredSources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
|
||||
Map<String,WorkflowFacts.Overseas> overseas=new HashMap<>();
|
||||
if(!sources.isEmpty()) {
|
||||
try {
|
||||
@@ -40,8 +42,10 @@ public class WorkflowTabService {
|
||||
if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable("尾程事实包含空记录或本批以外的客户/入仓来源");
|
||||
overseas.merge(fact.tenantId()+":"+fact.inboundId(),fact,(a,b)->new WorkflowFacts.Overseas(a.tenantId(),a.inboundId(),a.outboundExists()||b.outboundExists(),a.pendingOutbound()||b.pendingOutbound(),a.delivering()||b.delivering(),a.unloaded()&&b.unloaded()));
|
||||
}
|
||||
if(overseas.size()!=sources.size()) throw unavailable("尾程事实缺少来源 expected="+sources.size()+" actual="+overseas.size()
|
||||
+" missing="+sources.stream().filter(s->!overseas.containsKey(s.tenantId()+":"+s.inboundId())).limit(20).toList());
|
||||
// 未开船来源可能尚未产生尾程订单;已开船来源仍须完整返回,避免依赖缺项被误当作零。
|
||||
var missing=requiredSources.stream().filter(s->!overseas.containsKey(s.tenantId()+":"+s.inboundId())).toList();
|
||||
if(!missing.isEmpty()) throw unavailable("尾程事实缺少来源 expected="+requiredSources.size()+" actual="+(requiredSources.size()-missing.size())
|
||||
+" missing="+missing.stream().limit(20).toList());
|
||||
} catch(BizException e) { throw e; } catch(Exception e) {
|
||||
throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试").initCause(e);
|
||||
}
|
||||
@@ -61,6 +65,11 @@ public class WorkflowTabService {
|
||||
Set<String> keys=new LinkedHashSet<>();
|
||||
if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW");
|
||||
boolean signed="DELIVERED".equals(process);
|
||||
// 有效出库与派送事实独立于船期、清关资料;签收后不再保留待出库或派送任务。
|
||||
if(!signed && o!=null) {
|
||||
if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT");
|
||||
if(o.delivering()) keys.add("DELIVERING");
|
||||
}
|
||||
// 人工补录或船司查询的实际开船节点才推进阶段,报关完成和提前上传资料不能代替开船。
|
||||
boolean clearanceStage=f.departed();
|
||||
if(!clearanceStage && !signed) {
|
||||
@@ -75,8 +84,6 @@ public class WorkflowTabService {
|
||||
if(!o.outboundExists() && !o.unloaded()) keys.add("WAIT_UNLOAD");
|
||||
// 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。
|
||||
if(!o.outboundExists()) keys.add("WAIT_DISPATCH");
|
||||
if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT");
|
||||
if(o.delivering()) keys.add("DELIVERING");
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
|
||||
@@ -1679,7 +1679,7 @@ components:
|
||||
type: string
|
||||
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW]
|
||||
nullable: true
|
||||
description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,报关完成或实际开船后移出;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;生成出库单后进入 OUTBOUND_DOCUMENT,实际出库后进入 DELIVERING,派送完成进入 SIGNED。
|
||||
description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,报关完成或实际开船后移出;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;尾程排车理货审核通过并生成有效出库单后进入 OUTBOUND_DOCUMENT,不以前置开船节点或清关资料为条件;取消或实际出库后移出,实际出库并发车后进入 DELIVERING,派送完成进入 SIGNED。
|
||||
customerId: {type: integer, format: int64, nullable: true}
|
||||
receivingWarehouseId: {type: integer, format: int64, nullable: true}
|
||||
destinationWarehouseId: {type: integer, format: int64, nullable: true}
|
||||
|
||||
+5
-3
@@ -11,14 +11,15 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LoadingEvidenceSqlContractTest {
|
||||
@Test
|
||||
void schemaOwnsEvidenceByCabinetWithOneActiveFactPerType() throws Exception {
|
||||
void schemaOwnsMultipleEvidencePhotosByCabinetAndKeepsOperationIdempotency() throws Exception {
|
||||
String sql = new ClassPathResource("db/phase3-loading-delivery-schema.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(sql)
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence")
|
||||
.contains("container_booking_id BIGINT UNSIGNED NOT NULL")
|
||||
.contains("source_task_id BIGINT UNSIGNED NOT NULL")
|
||||
.contains("UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag)")
|
||||
.contains("KEY idx_cabinet_evidence_active (container_booking_id,evidence_type,active_flag)")
|
||||
.doesNotContain("UNIQUE KEY uk_cabinet_evidence_active")
|
||||
.contains("UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key)")
|
||||
.contains("DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci")
|
||||
.contains("FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)")
|
||||
@@ -31,7 +32,8 @@ class LoadingEvidenceSqlContractTest {
|
||||
Path unified = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
String sql = Files.readString(unified, StandardCharsets.UTF_8);
|
||||
assertThat(sql).contains("CREATE TABLE fl_loading_cabinet_evidence")
|
||||
.contains("uk_cabinet_evidence_active")
|
||||
.contains("idx_cabinet_evidence_active")
|
||||
.doesNotContain("uk_cabinet_evidence_active")
|
||||
.contains("uk_cabinet_evidence_operation")
|
||||
.contains("FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id)")
|
||||
.doesNotContain("CREATE TABLE fl_loading_evidence")
|
||||
|
||||
+5
-4
@@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ContainerWorkbenchSqlContractTest {
|
||||
@Test
|
||||
void pagesDomesticAndOverseasScopesFromOwnedActualFactsWithoutCopyingShipNodeSql() throws Exception {
|
||||
void pagesDomesticAndOverseasScopesFromDispatchAndEmptyReturnFacts() throws Exception {
|
||||
String sql = String.join("\n", ContainerBookingMapper.class
|
||||
.getMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class,
|
||||
com.baiyue.firstleg.dto.ContainerDtos.Query.class, String.class)
|
||||
@@ -19,9 +19,10 @@ class ContainerWorkbenchSqlContractTest {
|
||||
|
||||
assertThat(sql).contains("#{workbench}='DOMESTIC'", "#{workbench}='OVERSEAS'",
|
||||
"#{query.scope}='ACTIVE'", "#{query.scope}='HISTORY'", "fl_outbound_dispatch",
|
||||
"confirmed_at IS NOT NULL", "b.actual_empty_reported_at IS NULL",
|
||||
"b.actual_empty_reported_at IS NOT NULL", "outbound_dispatch_status")
|
||||
.doesNotContain("fl_carrier_schedule_query", "fl_carrier_schedule_node_fact");
|
||||
"confirmed_at IS NOT NULL", "fl_terminal_drayage returned",
|
||||
"returned.actual_empty_return_at IS NOT NULL", "returned_node.standard_node='EMPTY_RETURNED'",
|
||||
"returned_node.actual_at IS NOT NULL", "outbound_dispatch_status")
|
||||
.doesNotContain("fl_carrier_schedule_query", "b.actual_empty_reported_at");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+21
-17
@@ -32,9 +32,6 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -178,7 +175,7 @@ class FbaPricingRepositoryRuleListTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void feeSendFromLegacyFlowAdvancesToWaitingPallet() {
|
||||
void feeSendPreservesFulfillmentAndRequiresApprovedMeasurement() {
|
||||
when(pricing.update(org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any()))
|
||||
.thenReturn(1);
|
||||
|
||||
@@ -188,25 +185,32 @@ class FbaPricingRepositoryRuleListTest {
|
||||
ArgumentCaptor<LambdaUpdateWrapper<InboundPricingEntity>> wrapper =
|
||||
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
||||
verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture());
|
||||
assertThat(wrapper.getValue().getSqlSet()).contains("feeStatus").contains("processStatus");
|
||||
assertThat(wrapper.getValue().getCustomSqlSegment()).contains("tenantId", "id", "processStatus", "feeStatus");
|
||||
assertThat(wrapper.getValue().getSqlSet()).contains("feeStatus").doesNotContain("processStatus");
|
||||
assertThat(wrapper.getValue().getCustomSqlSegment())
|
||||
.contains("tenantId", "id", "processStatus", "measureReviewStatus", "feeStatus");
|
||||
assertThat(params(wrapper.getValue()).values())
|
||||
.contains(10001L, 88L, "FEE_CONFIRMING", "CONFIRMED", "SENT", "WAIT_PALLET");
|
||||
.contains(10001L, 88L, "FEE_CONFIRMING", "APPROVED", "CONFIRMED", "SENT")
|
||||
.doesNotContain("WAIT_PALLET");
|
||||
}
|
||||
|
||||
/** 费用确认状态集覆盖待组托及后续履约阶段。 */
|
||||
@Test
|
||||
void feeConfirmationAllowsPalletizationAndPostLoadingInboundStatuses() {
|
||||
String source;
|
||||
try {
|
||||
source = Files.readString(Path.of(
|
||||
"src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java"),
|
||||
StandardCharsets.UTF_8);
|
||||
} catch (java.io.IOException exception) {
|
||||
throw new AssertionError(exception);
|
||||
}
|
||||
assertThat(source)
|
||||
.contains("List.of(\"FEE_CONFIRMING\", \"WAIT_PALLET\", \"WAIT_PALLETIZE\", \"WAIT_SPLIT\", \"LOADING\", \"LOADED\", \"DELIVERED\")");
|
||||
when(pricing.update(org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any()))
|
||||
.thenReturn(1);
|
||||
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isEqualTo(1);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<LambdaUpdateWrapper<InboundPricingEntity>> wrapper =
|
||||
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
||||
verify(pricing).update(org.mockito.ArgumentMatchers.isNull(), wrapper.capture());
|
||||
assertThat(wrapper.getValue().getSqlSet()).contains("feeStatus").doesNotContain("processStatus");
|
||||
assertThat(wrapper.getValue().getCustomSqlSegment())
|
||||
.contains("processStatus", "measureReviewStatus", "feeStatus");
|
||||
assertThat(params(wrapper.getValue()).values())
|
||||
.contains("WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT",
|
||||
"LOADING", "LOADED", "DELIVERED", "APPROVED", "CALCULATED", "CONFIRMED");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+2
-1
@@ -45,7 +45,8 @@ class Phase3FinalUnifiedSqlContractTest {
|
||||
void unifiedEntryKeepsFinalConstraintsAndImmutableTriggers() throws Exception {
|
||||
String sql = sql();
|
||||
assertThat(sql)
|
||||
.contains("UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag)")
|
||||
.contains("KEY idx_cabinet_evidence_active (container_booking_id,evidence_type,active_flag)")
|
||||
.doesNotContain("UNIQUE KEY uk_cabinet_evidence_active")
|
||||
.contains("UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key)")
|
||||
.contains("UNIQUE KEY uk_inbound_one_active_remainder")
|
||||
.contains("UNIQUE KEY uk_inbound_split_source_once")
|
||||
|
||||
@@ -36,7 +36,7 @@ class WorkflowBoundaryTest {
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure);
|
||||
}
|
||||
|
||||
/** 报关完成或提前上传资料仍留在出口报关,实际开船后才查询精确海外来源。 */
|
||||
/** 未开船也查询尾程建单事实,未产生尾程来源时仍保留国内阶段。 */
|
||||
@Test void completedCustomsAndClearanceUploadWaitForActualDeparture() {
|
||||
var repository=mock(WorkflowFactRepository.class);
|
||||
var downstream=mock(LastlegWorkflowClient.class);
|
||||
@@ -45,12 +45,15 @@ class WorkflowBoundaryTest {
|
||||
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
|
||||
when(repository.facts(List.of(1L))).thenReturn(List.of(
|
||||
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true)));
|
||||
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of()));
|
||||
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS");
|
||||
verifyNoInteractions(downstream);
|
||||
verify(downstream).read(any(),any());
|
||||
clearInvocations(downstream);
|
||||
when(repository.facts(List.of(1L))).thenReturn(List.of(
|
||||
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,true)));
|
||||
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS");
|
||||
verifyNoInteractions(downstream);
|
||||
verify(downstream).read(any(),any());
|
||||
clearInvocations(downstream);
|
||||
when(repository.facts(List.of(1L))).thenReturn(List.of(
|
||||
new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true,true)));
|
||||
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
|
||||
@@ -64,4 +67,18 @@ class WorkflowBoundaryTest {
|
||||
verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1)))));
|
||||
}
|
||||
|
||||
/** 理货审核建单后,未补录开船与清关资料也必须返回出库待办。 */
|
||||
@Test void outboundBeforeDepartureIsReadAndClassified() {
|
||||
var repository=mock(WorkflowFactRepository.class);
|
||||
var downstream=mock(LastlegWorkflowClient.class);
|
||||
var service=new WorkflowTabService(repository,downstream);
|
||||
var inbound=new InboundOrderEntity();inbound.setId(1L);inbound.setTenantId(11L);
|
||||
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
|
||||
when(repository.facts(List.of(1L))).thenReturn(List.of(
|
||||
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,false,false)));
|
||||
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
|
||||
new WorkflowFacts.Overseas(11,1,true,true,false,true))));
|
||||
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("OUTBOUND_DOCUMENT");
|
||||
verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1)))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,12 +30,12 @@ class WorkflowBusinessTest {
|
||||
assertThat(WorkflowTabService.classify(uploaded,"DELIVERED",unloaded))
|
||||
.contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS");
|
||||
}
|
||||
/** 清关前不能被海外建单事实提前推进,清关后出库与派送依次替换待办。 */
|
||||
/** 出库与派送按尾程事实展示,不受清关资料限制。 */
|
||||
@Test void downstreamFactsCannotBypassClearanceAndOutboundReplacesQueues() {
|
||||
var pending=new WorkflowFacts.Overseas(11,1,true,true,false,false);
|
||||
var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,false);
|
||||
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",pending)).containsExactly("SAILING_CLEARANCE");
|
||||
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",shipped)).containsExactly("SAILING_CLEARANCE");
|
||||
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",pending)).containsExactly("OUTBOUND_DOCUMENT","SAILING_CLEARANCE");
|
||||
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",shipped)).containsExactly("DELIVERING","SAILING_CLEARANCE");
|
||||
assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",pending)).containsExactly("OUTBOUND_DOCUMENT");
|
||||
assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",shipped)).containsExactly("DELIVERING");
|
||||
assertThat(WorkflowTabService.classify(fact(1,true),"DELIVERED",shipped)).containsExactly("SIGNED");
|
||||
|
||||
+10
-6
@@ -7,9 +7,12 @@ import com.baiyue.integration.dto.CarrierScheduleDtos.ExceptionObservation;
|
||||
import com.baiyue.integration.provider.CarrierScheduleProviderAdapter;
|
||||
import com.baiyue.integration.provider.CompliantCarrierScheduleProviderAdapter;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -25,21 +28,22 @@ class CarrierScheduleProviderServiceTest {
|
||||
assertThat(result.exceptions()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlyDispatchesNormalizedContainerNumberTracking() {
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"ZCSU7881970", "ZCSU7881971", "LOCAL-2026-001"})
|
||||
void onlyDispatchesNormalizedContainerNumberTracking(String containerNo) {
|
||||
CarrierScheduleProviderAdapter capturing = new CarrierScheduleProviderAdapter() {
|
||||
@Override public boolean supports(String carrierCode) { return true; }
|
||||
@Override public QueryResult query(QueryRequest request) {
|
||||
assertThat(request.trackingType()).isEqualTo("CONTAINER_NO");
|
||||
assertThat(request.trackingNo()).isEqualTo("ZCSU7881970");
|
||||
assertThat(request.trackingNo()).isEqualTo(containerNo);
|
||||
return new QueryResult("TEST", "NO_CHANGE", List.of(), List.of(), null, null, LocalDateTime.now());
|
||||
}
|
||||
};
|
||||
var service = new CarrierScheduleProviderService(List.of(capturing));
|
||||
service.query(new QueryRequest("ZIM", "CONTAINER_NO", "ZCSU7881970"));
|
||||
assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "CONTAINER_NO", "zcsu7881970")))
|
||||
service.query(new QueryRequest("ZIM", "CONTAINER_NO", containerNo));
|
||||
assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "CONTAINER_NO", containerNo.toLowerCase(Locale.ROOT))))
|
||||
.hasMessageContaining("规范化真实集装箱号");
|
||||
assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "CONTAINER_NO", "ZCSU7881971")))
|
||||
assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "CONTAINER_NO", " " + containerNo + " ")))
|
||||
.hasMessageContaining("规范化真实集装箱号");
|
||||
assertThatThrownBy(() -> service.query(new QueryRequest("ZIM", "BILL_OF_LADING", "BL-1")))
|
||||
.hasMessageContaining("CONTAINER_NO");
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
|
||||
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。普通 OVERSEAS 允许在本票已预留托盘间按现场实际分配箱数,逐托建议箱数不作为收货上限,仍校验本票总箱数、单托66箱、目的仓及客户归属;库存按已扫箱码的SKU和真实托盘归属入账。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,托盘校验和提交均只确认本票已预留绑定。Admin 入库单详情可按行打印已预留真实托盘标签,复用海外托盘列表权限和标签PDF接口,不生成第二个托盘码 |
|
||||
| 普通散货收货短少确认 | `inboundplan/controller/InboundPlanController.java` 的 `/{planNo}/confirm-shortage` → `InboundPdaWorkflowService.confirmShortage`;仅平台入库编辑权限指定客户操作,要求待差异核对、原版本及应收/实收快照、原因和幂等键。FBA仅同柜次同目的仓收货组单票且冻结申报单SKU可按总数确认,多SKU及跨票自动分摊拒绝;普通OVERSEAS按已扫箱码精确核对SKU与逐托实收,可处理多SKU短少,一件代发整托货物拒绝此操作。逐SKU实收不得超原申报,确认后保留应收与现场结束时间,按实收生成库存和原计租批次,OVERSEAS已收箱转AVAILABLE、未收箱保留原状态,计划/PDA任务进入 `PARTIAL_INBOUND`,尾程订单完成入库;同事务交接原预排,明确零实收的托盘同步移除预排明细、释放本车占用及预约分配并重算整车货量;缺少零实收证据或已有拣货拒绝。不重复发布卸柜事件。无实收、超收或逐SKU事实不一致拒绝;空预留托盘不能再次作为计划货源排车。 |
|
||||
| 收货异常统一核对 | Admin 主入口为 `/{planNo}/receiving-reconciliation` GET/POST → `InboundReceivingReconciliationService`,契约 `inbound-reconciliation-v1.yaml`;按来源柜次、柜号和实际收货仓核对整柜,独立展示实盘与已归属数量。普通FBA支持明确逐托逐票SKU确认短少、超收、零实收和误录更正,每托守恒,原申报和现场时间保留;完成票据及普通地址逐箱身份只读,一件代发独立分组。旧生产 `confirm-shortage` 保留原语义。确认采用当前事实摘要、原因和幂等键,库存、实际体积、托盘、原预排和原始/确认后审计同事务提交,逐SKU短少不能被其他SKU超收抵消。 |
|
||||
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
|
||||
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并复用 `FILE_SERVICE_URL` 只读访问 File 服务,单图不可用不改变拆柜状态 |
|
||||
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||
|
||||
+9
-5
@@ -19,7 +19,8 @@ import java.util.Set;
|
||||
public class HttpDriverFileGateway implements DriverFileGateway {
|
||||
static final String TYPE = "FLEET_DRIVER_LICENSE";
|
||||
static final long MAX_SIZE = 10L * 1024 * 1024;
|
||||
private static final Set<String> CONTENT_TYPES = Set.of("application/pdf", "image/jpeg", "image/png");
|
||||
private static final Set<String> READ_CONTENT_TYPES = Set.of("application/pdf", "image/jpeg", "image/png");
|
||||
private static final Set<String> UPLOAD_CONTENT_TYPES = Set.of("image/jpeg", "image/png");
|
||||
private static final String PATH = "/internal/file/v1/business-files/0/FLEET_DRIVER_LICENSE/{driver}";
|
||||
private final RestClient client;
|
||||
private final String token;
|
||||
@@ -31,10 +32,11 @@ public class HttpDriverFileGateway implements DriverFileGateway {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
/** 新上传的驾驶证只接受 JPG/PNG,读取历史附件继续按原文件元数据校验。 */
|
||||
@Override
|
||||
public DriverModels.File upload(long driverId, String operatorId, MultipartFile file) {
|
||||
if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !CONTENT_TYPES.contains(Objects.toString(file.getContentType(), "")))
|
||||
throw bad("驾驶证附件须为非空的 PDF、JPG 或 PNG,最大 10MiB");
|
||||
if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !UPLOAD_CONTENT_TYPES.contains(Objects.toString(file.getContentType(), "")))
|
||||
throw bad("驾驶证附件须为非空的 JPG 或 PNG,最大 10MiB");
|
||||
var body = new MultipartBodyBuilder();
|
||||
try {
|
||||
body.part("file", new ByteArrayResource(file.getBytes()) {
|
||||
@@ -45,7 +47,9 @@ public class HttpDriverFileGateway implements DriverFileGateway {
|
||||
.header("X-Operator-Id", operatorId).contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
|
||||
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); })
|
||||
.body(Envelope.class);
|
||||
return verified(result, driverId, null);
|
||||
DriverModels.File uploaded = verified(result, driverId, null);
|
||||
if (!UPLOAD_CONTENT_TYPES.contains(uploaded.contentType())) throw bad("驾驶证附件须为 JPG 或 PNG");
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -72,7 +76,7 @@ public class HttpDriverFileGateway implements DriverFileGateway {
|
||||
if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L)
|
||||
|| !TYPE.equals(data.businessType()) || !Long.toString(driverId).equals(data.businessId())
|
||||
|| (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank()
|
||||
|| !CONTENT_TYPES.contains(Objects.toString(data.contentType(), "")) || data.sizeBytes() == null
|
||||
|| !READ_CONTENT_TYPES.contains(Objects.toString(data.contentType(), "")) || data.sizeBytes() == null
|
||||
|| data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE)
|
||||
throw bad("文件不属于当前司机或不是有效驾驶证附件");
|
||||
return new DriverModels.File(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes());
|
||||
|
||||
+22
-2
@@ -6,6 +6,15 @@ import java.util.List;
|
||||
/** 每趟只取首次真实发车快照;取货动作记录不能冒充仓库装车。 */
|
||||
@Mapper
|
||||
public interface TripOperationsQueryMapper {
|
||||
/** 发车边界和完成时间显式按 UTC DATETIME 读写,避免默认 Instant 处理器引入连接时区偏移。 */
|
||||
@ConstructorArgs({
|
||||
@Arg(column = "trip_no", javaType = String.class),
|
||||
@Arg(column = "status", javaType = String.class),
|
||||
@Arg(column = "completed_at", javaType = java.time.Instant.class, typeHandler = TripOperationsUtcInstantTypeHandler.class),
|
||||
@Arg(column = "snapshot", javaType = String.class),
|
||||
@Arg(column = "measured", javaType = String.class),
|
||||
@Arg(column = "transport_facts", javaType = String.class)
|
||||
})
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT t.trip_no,t.status,t.completed_at,
|
||||
@@ -25,9 +34,20 @@ public interface TripOperationsQueryMapper {
|
||||
(SELECT d.package_count FROM ll_dispatch_order d WHERE d.trip_id=t.id AND d.task_type='LOCAL_TRUCK' LIMIT 1)
|
||||
ELSE (SELECT SUM(p.actual_packages) FROM ll_fleet_pickup_task p
|
||||
WHERE CAST(p.trip_no AS BINARY(512))=CAST(t.trip_no AS BINARY(512)) AND p.status IN ('PICKED','ARRIVED')) END)
|
||||
ELSE NULL END AS transport_facts
|
||||
ELSE (SELECT JSON_OBJECT('taskType','PHOTO_LOOSEBOARD','packages',
|
||||
JSON_EXTRACT(dh.snapshot_json,'$.shippedPackageCount'))
|
||||
FROM ll_dispatch_order d JOIN ll_dispatch_history dh ON dh.dispatch_id=d.id
|
||||
WHERE d.trip_id=t.id AND dh.action='LOOSEBOARD_DELIVERING'
|
||||
AND JSON_EXTRACT(dh.snapshot_json,'$.departedAt') IS NOT NULL
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(dh.snapshot_json,'$.departedAt'))!='null'
|
||||
AND (JSON_EXTRACT(dh.snapshot_json,'$.loadingCompletedAt') IS NULL
|
||||
OR JSON_UNQUOTE(JSON_EXTRACT(dh.snapshot_json,'$.loadingCompletedAt'))='null')
|
||||
AND JSON_EXTRACT(dh.snapshot_json,'$.pickedPackageCount')=0
|
||||
AND JSON_EXTRACT(dh.snapshot_json,'$.shippedPackageCount') > 0
|
||||
ORDER BY dh.record_version,dh.id LIMIT 1) END AS transport_facts
|
||||
FROM ll_fleet_trip t
|
||||
WHERE t.departed_at >= #{q.from} AND t.departed_at < #{q.to}
|
||||
WHERE t.departed_at >= #{q.from,typeHandler=com.baiyue.lastleg.fleet.reporting.TripOperationsUtcInstantTypeHandler}
|
||||
AND t.departed_at < #{q.to,typeHandler=com.baiyue.lastleg.fleet.reporting.TripOperationsUtcInstantTypeHandler}
|
||||
<if test='q.warehouseId != null'>AND t.warehouse_id=#{q.warehouseId}</if>
|
||||
<if test='q.status != null and q.status != ""'>AND t.status=#{q.status}</if>
|
||||
ORDER BY t.departed_at DESC,t.id DESC LIMIT 5001
|
||||
|
||||
+7
-1
@@ -55,6 +55,10 @@ public class TripOperationsService {
|
||||
var trip=snapshot.trip(); var load=snapshot.confirmedLoad();
|
||||
if(trip!=null && Set.of("LOCAL_TRUCK","PICKUP").contains(Objects.toString(trip.tripPurpose(),"")))
|
||||
return transport(fact,trip);
|
||||
// 纯散板照片发运没有仓内实载,只接受查询取得的不可变照片发运依据。
|
||||
if(trip!=null && load==null && fact.transportFacts()!=null
|
||||
&& "PHOTO_LOOSEBOARD".equals(json.readTree(fact.transportFacts()).path("taskType").asText()))
|
||||
return transport(fact,trip);
|
||||
if (trip==null || !fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || load==null || load.cargo()==null || load.pallets()==null || load.cargo().isEmpty() || load.pallets().isEmpty())
|
||||
throw new IllegalStateException("发车历史缺少装载或实际时间,请核对车次"+fact.tripNo());
|
||||
var externalByPallet=new HashMap<String,Long>(); var money=new TreeMap<String,BigDecimal>(); var bookingNumbers=new HashSet<String>();
|
||||
@@ -111,9 +115,11 @@ public class TripOperationsService {
|
||||
if(!fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || trip.stops()==null || trip.stops().size()<2)
|
||||
throw new IllegalStateException("运输发车历史缺少行程或实际时间");
|
||||
var facts=fact.transportFacts()==null?null:json.readTree(fact.transportFacts());
|
||||
if(facts==null || !trip.tripPurpose().equals(facts.path("taskType").asText()))throw new IllegalStateException("运输出车缺少来源依据");
|
||||
boolean photo=facts!=null && "PHOTO_LOOSEBOARD".equals(facts.path("taskType").asText());
|
||||
if(facts==null || (!photo && !Objects.equals(trip.tripPurpose(),facts.path("taskType").asText())))throw new IllegalStateException("运输出车缺少来源依据");
|
||||
// 箱数统计仅累计已登记实提,未登记不计入,不能用预报补出运量。
|
||||
var packages=facts.get("packages");long quantity=packages==null || packages.isNull()?0:packages.longValue();
|
||||
if(photo && (packages==null || !packages.isIntegralNumber() || quantity<=0))throw new IllegalStateException("散板照片发运缺少实际箱数");
|
||||
if(quantity<0)throw new IllegalStateException("运输箱数不合法");
|
||||
var destinations=new ArrayList<TripOperationsModels.Destination>();
|
||||
for(int i=1;i<trip.stops().size();i++) {
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.lastleg.fleet.reporting;
|
||||
|
||||
import org.apache.ibatis.type.BaseTypeHandler;
|
||||
import org.apache.ibatis.type.JdbcType;
|
||||
|
||||
import java.sql.CallableStatement;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
/** 出车统计的 DATETIME 保存 UTC 本地时间,读写均不使用连接或进程的默认时区。 */
|
||||
public class TripOperationsUtcInstantTypeHandler extends BaseTypeHandler<Instant> {
|
||||
/** 查询边界按 UTC 墙钟时间绑定,与发车事实的入库语义一致。 */
|
||||
@Override
|
||||
public void setNonNullParameter(PreparedStatement statement, int index, Instant value, JdbcType jdbcType)
|
||||
throws SQLException {
|
||||
statement.setObject(index, LocalDateTime.ofInstant(value, ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
/** 按列名读取 UTC 完成时间,保留尚未完成的空值。 */
|
||||
@Override
|
||||
public Instant getNullableResult(ResultSet results, String column) throws SQLException {
|
||||
return toInstant(results.getObject(column, LocalDateTime.class));
|
||||
}
|
||||
|
||||
/** 构造器按列序读取时沿用相同的 UTC 时间语义。 */
|
||||
@Override
|
||||
public Instant getNullableResult(ResultSet results, int column) throws SQLException {
|
||||
return toInstant(results.getObject(column, LocalDateTime.class));
|
||||
}
|
||||
|
||||
/** 存储过程读取同样将 DATETIME 解释为 UTC,不补默认时间。 */
|
||||
@Override
|
||||
public Instant getNullableResult(CallableStatement statement, int column) throws SQLException {
|
||||
return toInstant(statement.getObject(column, LocalDateTime.class));
|
||||
}
|
||||
|
||||
/** 将数据库墙钟值还原为 UTC 时刻。 */
|
||||
private Instant toInstant(LocalDateTime value) {
|
||||
return value == null ? null : value.toInstant(ZoneOffset.UTC);
|
||||
}
|
||||
}
|
||||
+33
@@ -39,6 +39,39 @@ public class InboundPlanController {
|
||||
@Resource private ContainerInboundPlanConversionCoordinator containerConversion;
|
||||
@Resource private InboundFbaAppointmentService appointmentService;
|
||||
@Resource private com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow;
|
||||
@Resource private com.baiyue.lastleg.inboundplan.pda.service.InboundReceivingReconciliationService receivingReconciliation;
|
||||
|
||||
/** 平台读取同一来源柜次的现场清点与逐票商品归属。 */
|
||||
@GetMapping("/{planNo}/receiving-reconciliation")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
|
||||
@Operation(summary = "查询收货异常核对", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
|
||||
public ApiResponse<com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation.Snapshot> receivingReconciliation(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
|
||||
@RequestParam String customerId) {
|
||||
requireReconciliationPlatform(user);
|
||||
return ApiResponse.success(receivingReconciliation.snapshot(convertScope(user, customerId), planNo));
|
||||
}
|
||||
|
||||
/** 逐托逐商品确认短少、超收或误录,保留原申报和完整核对证据。 */
|
||||
@PostMapping("/{planNo}/receiving-reconciliation")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')")
|
||||
@Operation(summary = "确认收货异常", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT)
|
||||
public ApiResponse<com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation.Result> confirmReceivingReconciliation(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation.Confirm request) {
|
||||
requireReconciliationPlatform(user);
|
||||
String customerId = convertScope(user, request.customerId());
|
||||
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
return ApiResponse.success(receivingReconciliation.confirm(customerId, planNo, operatorId, idempotencyKey, request));
|
||||
}
|
||||
|
||||
/** 共托核对可能涉及多个客户,仅平台作业域允许读取和确认。 */
|
||||
private void requireReconciliationPlatform(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可核对收货异常");
|
||||
}
|
||||
}
|
||||
|
||||
/** 平台核对现场短少后按实收入库,不修改原应收数量。 */
|
||||
@PostMapping("/{planNo}/confirm-shortage")
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.baiyue.lastleg.inboundplan.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.util.List;
|
||||
|
||||
/** 平台按同柜收货事实核对异常,申报数量与确认实收分别保留。 */
|
||||
public final class InboundReceivingReconciliation {
|
||||
/** 仅承载请求和响应类型。 */
|
||||
private InboundReceivingReconciliation() { }
|
||||
|
||||
public record Snapshot(String planNo, String containerNo, String snapshotHash,
|
||||
int expectedPackageCount, int countedPackageCount, int attributedPackageCount,
|
||||
boolean canConfirm, String blockedReason, List<Plan> plans,
|
||||
List<Pallet> pallets, List<Receipt> receipts) { }
|
||||
|
||||
public record Plan(String customerId, String planNo, String customerName, String status,
|
||||
int expectedPackageCount, int attributedPackageCount) { }
|
||||
|
||||
public record Pallet(Long palletId, String palletNo, String groupKey,
|
||||
int countedPackageCount, int attributedPackageCount, boolean editable) { }
|
||||
|
||||
public record Receipt(String customerId, String planNo, Long lineId, String inboundCode,
|
||||
Long palletId, String palletNo, String skuCode, String productName,
|
||||
int expectedPackageCount, int receivedPackageCount, boolean editable) { }
|
||||
|
||||
public record Confirm(@NotBlank String customerId, @NotBlank String snapshotHash,
|
||||
@NotBlank @Size(max = 200) String reason,
|
||||
@NotNull List<@NotNull @Valid Count> pallets,
|
||||
@NotNull List<@NotNull @Valid Quantity> receipts) { }
|
||||
|
||||
public record Count(@NotNull Long palletId,
|
||||
@NotNull @Min(0) Integer countedPackageCount) { }
|
||||
|
||||
public record Quantity(@NotBlank String customerId, @NotBlank String planNo,
|
||||
@NotNull Long lineId, @NotNull Long palletId, @NotBlank String skuCode,
|
||||
@NotNull @Min(0) Integer receivedPackageCount) { }
|
||||
|
||||
public record Result(String planNo, String status, int confirmedPackageCount,
|
||||
List<String> reconciledPlanNos) { }
|
||||
}
|
||||
+8
-5
@@ -13,7 +13,8 @@ public final class ReceivingContainerResponses {
|
||||
List<String> destinationCodes, int plannedPalletCount,
|
||||
int totalPackages, int receivedPackages,
|
||||
boolean operable, boolean allPlansReady, String blockReason,
|
||||
LocalDateTime plannedArrivalAt, boolean currentAccount) {
|
||||
LocalDateTime plannedArrivalAt, boolean currentAccount, String cabinetNo) {
|
||||
/** 保留不含接柜仓与柜次号的既有响应构造方式。 */
|
||||
public Candidate(String sourceContainerBookingId, String containerNo, String status,
|
||||
int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount,
|
||||
int planCount, int plannedInboundCodeCount, int pendingPlanCount,
|
||||
@@ -23,7 +24,7 @@ public final class ReceivingContainerResponses {
|
||||
this(sourceContainerBookingId, containerNo, null, null, status, totalOrderCount,
|
||||
inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount,
|
||||
pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages,
|
||||
receivedPackages, operable, allPlansReady, blockReason, plannedArrivalAt, currentAccount);
|
||||
receivedPackages, operable, allPlansReady, blockReason, plannedArrivalAt, currentAccount, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +37,8 @@ public final class ReceivingContainerResponses {
|
||||
LocalDateTime preparedAt, LocalDateTime signedAt,
|
||||
LocalDateTime receivingAt, LocalDateTime completedAt,
|
||||
int totalPackages, int receivedPackages, int pendingPackages,
|
||||
boolean hasDifference, List<Inbound> inbounds) {
|
||||
boolean hasDifference, List<Inbound> inbounds, String cabinetNo) {
|
||||
/** 旧会话构造缺少接柜仓编码及柜次号时保持空值。 */
|
||||
public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName,
|
||||
String status, String operatorName, int version, List<SignedPhoto> photos,
|
||||
LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime receivingAt,
|
||||
@@ -44,9 +46,10 @@ public final class ReceivingContainerResponses {
|
||||
int pendingPackages, List<Inbound> inbounds) {
|
||||
this(containerNo, overseasWarehouseId, null, overseasWarehouseName, status, operatorName,
|
||||
version, photos, List.of(), preparedAt, signedAt, receivingAt, completedAt, totalPackages,
|
||||
receivedPackages, pendingPackages, false, inbounds);
|
||||
receivedPackages, pendingPackages, false, inbounds, null);
|
||||
}
|
||||
|
||||
/** 保留不含柜次号及国内照片的既有会话构造方式。 */
|
||||
public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseCode,
|
||||
String overseasWarehouseName, String status, String operatorName, int version,
|
||||
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
|
||||
@@ -54,7 +57,7 @@ public final class ReceivingContainerResponses {
|
||||
int receivedPackages, int pendingPackages, List<Inbound> inbounds) {
|
||||
this(containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName,
|
||||
status, operatorName, version, photos, List.of(), preparedAt, signedAt, receivingAt,
|
||||
completedAt, totalPackages, receivedPackages, pendingPackages, false, inbounds);
|
||||
completedAt, totalPackages, receivedPackages, pendingPackages, false, inbounds, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.mapper;
|
||||
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
|
||||
/** 异常核对严格使用来源柜次、柜号与实际接柜仓共同确定作业范围。 */
|
||||
@Mapper
|
||||
public interface InboundReceivingReconciliationMapper {
|
||||
/** 同柜共托需要一起核对,平台确认时按计划主键顺序锁定。 */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT p.* FROM ll_inbound_plan selected
|
||||
JOIN ll_order source ON source.id=selected.source_order_id AND source.customer_id=selected.customer_id
|
||||
JOIN ll_inbound_plan p ON p.container_no=selected.container_no
|
||||
AND p.overseas_warehouse_id=selected.overseas_warehouse_id
|
||||
JOIN ll_order o ON o.id=p.source_order_id AND o.customer_id=p.customer_id
|
||||
AND CAST(o.source_container_booking_id AS BINARY(512))=CAST(source.source_container_booking_id AS BINARY(512))
|
||||
WHERE selected.customer_id=#{customerId} AND selected.plan_no=#{planNo}
|
||||
AND NULLIF(TRIM(source.source_container_booking_id),'') IS NOT NULL
|
||||
ORDER BY p.id <if test='lock'>FOR UPDATE</if>
|
||||
</script>
|
||||
""")
|
||||
@Options(flushCache = Options.FlushCachePolicy.TRUE, useCache = false)
|
||||
List<InboundPlan> scopePlans(@Param("customerId") String customerId,
|
||||
@Param("planNo") String planNo, @Param("lock") boolean lock);
|
||||
|
||||
/** 完成会话已释放活动键,仍按完整来源身份定位并锁定原会话。 */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT * FROM ll_receiving_container_session
|
||||
WHERE CAST(source_container_booking_id AS BINARY(512))=CAST(#{bookingId} AS BINARY(512))
|
||||
AND container_no=#{containerNo} AND overseas_warehouse_id=#{warehouseId}
|
||||
ORDER BY created_at DESC,id DESC LIMIT 1 <if test='lock'>FOR UPDATE</if>
|
||||
</script>
|
||||
""")
|
||||
@Options(flushCache = Options.FlushCachePolicy.TRUE, useCache = false)
|
||||
ReceivingContainerSession session(@Param("bookingId") String bookingId,
|
||||
@Param("containerNo") String containerNo, @Param("warehouseId") Long warehouseId,
|
||||
@Param("lock") boolean lock);
|
||||
|
||||
/** 现场实盘与货件分配独立读取,不能将申报内分配数当作完整实盘。 */
|
||||
@Select("SELECT * FROM ll_receiving_fba_pallet_count WHERE session_id=#{sessionId} ORDER BY group_key,pallet_code,id")
|
||||
List<ReceivingFbaPalletCount> physicalCounts(Long sessionId);
|
||||
}
|
||||
+15
@@ -17,6 +17,21 @@ public interface ReceivingCandidateMapper {
|
||||
|
||||
record AvailableOrder(Long orderId, String bookingId) { }
|
||||
|
||||
/** 按稳定柜次身份批量读取本库装柜事件中的正式柜次号,不从柜号或内部 ID 推导。 */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT o.source_container_booking_id AS booking_id,
|
||||
MAX(NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(e.payload_json,'$.cabinetNo'))),'null'),'')) AS cabinet_no
|
||||
FROM ll_order o LEFT JOIN ll_loading_event_inbox e ON e.event_id=o.source_event_id
|
||||
WHERE o.source_container_booking_id IN
|
||||
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
GROUP BY o.source_container_booking_id
|
||||
</script>
|
||||
""")
|
||||
List<CabinetNumber> cabinetNumbers(@Param("ids") List<String> ids);
|
||||
|
||||
record CabinetNumber(String bookingId, String cabinetNo) { }
|
||||
|
||||
/** 保留原转换命令的客户和计划精确匹配,一批读取未确认计划。 */
|
||||
@Select("<script>SELECT p.id FROM ll_inbound_plan p JOIN ll_order o ON o.customer_id=p.customer_id " +
|
||||
"AND o.id=p.source_order_id WHERE p.id IN " +
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.*;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.*;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
import java.util.Collection;
|
||||
|
||||
/** 将异常核对的持久化访问集中到 Mapper,业务确认规则由服务负责。 */
|
||||
@Repository
|
||||
public class InboundReceivingReconciliationRepository {
|
||||
@Resource private InboundReceivingReconciliationMapper queries;
|
||||
@Resource private ReceivingFbaPalletCountMapper physical;
|
||||
@Resource private ReceivingSkuReceiptMapper skus;
|
||||
@Resource private ReceivingContainerSessionMapper sessions;
|
||||
@Resource private InboundStockReceiptMapper stockReceipts;
|
||||
@Resource private LastlegOrderMapper orders;
|
||||
|
||||
/** 查询或顺序锁定同一来源柜次的全部计划。 */
|
||||
public List<InboundPlan> plans(String customerId, String planNo, boolean lock) {
|
||||
return queries.scopePlans(customerId, planNo, lock);
|
||||
}
|
||||
|
||||
/** 精确读取已结束的原收货会话。 */
|
||||
public ReceivingContainerSession session(String bookingId, String containerNo, Long warehouseId, boolean lock) {
|
||||
return queries.session(bookingId, containerNo, warehouseId, lock);
|
||||
}
|
||||
|
||||
/** 读取独立保存的现场实盘。 */
|
||||
public List<ReceivingFbaPalletCount> physicalCounts(Long sessionId) {
|
||||
return queries.physicalCounts(sessionId);
|
||||
}
|
||||
|
||||
/** 已完成票据采用真实入库凭证还原 SKU,不以空收货行推断零实收。 */
|
||||
public List<InboundStockReceipt> stockReceipts(String customerId, Long taskId) {
|
||||
return stockReceipts.listTask(customerId, taskId);
|
||||
}
|
||||
|
||||
/** 异常入库复用原订单快照,业务服务继续复核每条客户归属。 */
|
||||
public List<LastlegOrder> orders(Collection<Long> orderIds) {
|
||||
return orders.selectBatchIds(orderIds);
|
||||
}
|
||||
|
||||
/** 查询本票已经明确保存的逐托 SKU 实收。 */
|
||||
public List<ReceivingSkuReceipt> skuReceipts(String customerId, Long planId) {
|
||||
return skus.listPlan(customerId, planId);
|
||||
}
|
||||
|
||||
/** 仅替换本客户本计划待核对的 SKU 归属,原操作日记保留完整前后证据。 */
|
||||
public void replaceSkuReceipts(String customerId, Long planId, List<ReceivingSkuReceipt> confirmed) {
|
||||
skus.delete(new LambdaQueryWrapper<ReceivingSkuReceipt>()
|
||||
.eq(ReceivingSkuReceipt::getCustomerId, customerId).eq(ReceivingSkuReceipt::getPlanId, planId));
|
||||
confirmed.forEach(skus::insert);
|
||||
}
|
||||
|
||||
/** 确认实盘时更新当前清点,历史原始输入保留在收货与核对操作日记。 */
|
||||
public void savePhysical(ReceivingFbaPalletCount count, boolean existing) {
|
||||
if (!existing) physical.insert(count);
|
||||
else if (physical.updateById(count) != 1) throw new IllegalStateException("现场清点记录已变化");
|
||||
}
|
||||
|
||||
/** 会话版本使并发核对和旧页面快照失效。 */
|
||||
public void updateSession(ReceivingContainerSession session) {
|
||||
if (sessions.updateById(session) != 1) throw new IllegalStateException("收货会话已变化");
|
||||
}
|
||||
}
|
||||
+8
@@ -41,6 +41,14 @@ public class ReceivingCandidateRepository {
|
||||
return sessions.findActiveContainers();
|
||||
}
|
||||
|
||||
/** 柜次号按批次读取,缺失快照时保持为空,避免候选列表逐柜查询。 */
|
||||
public Map<String, String> cabinetNumbers(List<String> bookingIds) {
|
||||
return batch(bookingIds.stream().distinct().toList(), candidates::cabinetNumbers).stream()
|
||||
.filter(row -> row.cabinetNo() != null && !row.cabinetNo().isBlank())
|
||||
.collect(Collectors.toMap(ReceivingCandidateMapper.CabinetNumber::bookingId,
|
||||
row -> row.cabinetNo().trim()));
|
||||
}
|
||||
|
||||
public record LineKey(String customerId, Long planId, Long lineId) { }
|
||||
public record Facts(List<LastlegOrder> orders, List<InboundPlan> plans, List<InboundPlanLine> lines,
|
||||
Set<Long> availableOrderIds, Set<Long> unconfirmedPlans, Map<LineKey,Integer> received) { }
|
||||
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation.*;
|
||||
import com.baiyue.lastleg.inboundplan.entity.*;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.*;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku;
|
||||
import com.baiyue.lastleg.inboundplan.pda.repository.InboundReceivingReconciliationRepository;
|
||||
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.annotation.Isolation;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 平台显式核对逐托、逐票和逐 SKU 实收,统一处理短少、超收及清点误录。 */
|
||||
@Service
|
||||
public class InboundReceivingReconciliationService {
|
||||
@Resource private InboundReceivingReconciliationRepository reconciliation;
|
||||
@Resource private PdaWorkflowRepository repository;
|
||||
@Resource private InboundStockReceiptService stockReceipts;
|
||||
@Resource private InboundDispatchReceiptGateway dispatchReceipts;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
private static final String ACTION = "CONFIRM_RECEIVING_DIFFERENCE";
|
||||
|
||||
/** 展示独立实盘和已归属数量,共托来源在同一快照中逐项核对。 */
|
||||
@Transactional(readOnly = true)
|
||||
public Snapshot snapshot(String customerId, String planNo) {
|
||||
List<InboundPlan> plans = reconciliation.plans(customerId, planNo, false);
|
||||
return present(planNo, load(customerId, planNo, plans, false));
|
||||
}
|
||||
|
||||
/** 运输父锁先于会话和来源锁;库存、清点更正、状态和审计同事务提交。 */
|
||||
@Transactional(isolation = Isolation.READ_COMMITTED)
|
||||
public Result confirm(String customerId, String planNo, String operatorId, String idempotencyKey, Confirm request) {
|
||||
if (!Objects.equals(customerId, request.customerId()) || operatorId == null || operatorId.isBlank()
|
||||
|| idempotencyKey == null || idempotencyKey.isBlank() || idempotencyKey.length() > 128
|
||||
|| request.reason() == null || request.reason().isBlank()) {
|
||||
throw conflict("异常确认必须提供客户、操作人、确认原因及有效请求键");
|
||||
}
|
||||
List<InboundPlan> original = reconciliation.plans(customerId, planNo, false);
|
||||
requireSelected(original, customerId, planNo);
|
||||
List<Long> planIds = original.stream().map(InboundPlan::getId).toList();
|
||||
dispatchReceipts.lock(planIds);
|
||||
Scope scope = load(customerId, planNo, original, true);
|
||||
Map<String, Object> command = Map.of("planNo", planNo, "operatorId", operatorId, "request", request);
|
||||
InboundPdaOperation replay = repository.operation(customerId, idempotencyKey);
|
||||
if (replay != null) {
|
||||
if (!ACTION.equals(replay.getAction()) || !hash(command).equals(replay.getRequestHash())) {
|
||||
throw conflict("请求键已用于其他异常确认,请刷新后重新操作");
|
||||
}
|
||||
return read(replay.getResultJson(), Result.class);
|
||||
}
|
||||
Snapshot before = present(planNo, scope);
|
||||
if (!before.canConfirm()) throw conflict(before.blockedReason());
|
||||
if (!before.snapshotHash().equals(request.snapshotHash())) throw conflict("收货记录已变化,请刷新后重新核对");
|
||||
validate(before, request);
|
||||
String sourceFacts = write(scope);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
applyPhysical(scope, before, request, now);
|
||||
for (PlanState state : scope.states()) {
|
||||
if (pending(state.plan())) applyReceipts(scope, state, request, now);
|
||||
}
|
||||
for (Pallet row : before.pallets()) {
|
||||
if (!row.editable()) continue;
|
||||
InboundPlanPallet pallet = scope.pallets().get(row.palletId());
|
||||
int count = request.pallets().stream().filter(value -> value.palletId().equals(row.palletId()))
|
||||
.findFirst().orElseThrow().countedPackageCount();
|
||||
pallet.setActualPackageCount(count);
|
||||
if (count > 0 && row.groupKey().startsWith("FBA:")) pallet.setAssignmentStatus("ASSIGNED");
|
||||
BigDecimal actualVolume = scope.states().stream().flatMap(state -> repository.allocations(state.plan().getId()).stream())
|
||||
.filter(allocation -> row.palletId().equals(allocation.getPalletId()))
|
||||
.map(allocation -> Objects.requireNonNullElse(allocation.getActualVolumeCbm(), BigDecimal.ZERO))
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(4, RoundingMode.HALF_UP);
|
||||
pallet.setActualVolumeCbm(actualVolume);
|
||||
repository.update(pallet);
|
||||
}
|
||||
List<String> completed = new ArrayList<>();
|
||||
for (PlanState state : scope.states()) {
|
||||
if (!pending(state.plan())) continue;
|
||||
boolean shortage = hasShortage(state, request.receipts());
|
||||
stockReceipts.stockInReconciled(state.plan().getCustomerId(), state.plan(), state.task(),
|
||||
state.lines(), repository.allocations(state.plan().getId()),
|
||||
new ArrayList<>(scope.pallets().values()), state.task().getCompletedAt());
|
||||
state.task().setStatus(shortage ? "PARTIAL_INBOUND" : "COMPLETED");
|
||||
state.task().setUpdatedAt(now);
|
||||
state.task().setVersion(value(state.task().getVersion()) + 1);
|
||||
repository.update(state.task());
|
||||
state.plan().setStatus(state.task().getStatus());
|
||||
state.plan().setUpdatedAt(now);
|
||||
repository.update(state.plan());
|
||||
completed.add(state.plan().getPlanNo());
|
||||
}
|
||||
dispatchReceipts.complete(scope.states().stream().filter(state -> completed.contains(state.plan().getPlanNo()))
|
||||
.map(state -> state.plan().getId()).toList(), operatorId, now);
|
||||
scope.session().setUpdatedAt(now);
|
||||
reconciliation.updateSession(scope.session());
|
||||
InboundPlan selected = requireSelected(scope.states().stream().map(PlanState::plan).toList(), customerId, planNo);
|
||||
int confirmed = request.receipts().stream().filter(row -> planNo.equals(row.planNo()))
|
||||
.mapToInt(Quantity::receivedPackageCount).sum();
|
||||
Result result = new Result(planNo, selected.getStatus(), confirmed, List.copyOf(completed));
|
||||
InboundPdaOperation audit = new InboundPdaOperation();
|
||||
audit.setId(IdWorker.getId()); audit.setCustomerId(customerId);
|
||||
audit.setTaskId(scope.states().stream().filter(state -> state.plan().getId().equals(selected.getId()))
|
||||
.findFirst().orElseThrow().task().getId());
|
||||
audit.setAction(ACTION); audit.setIdempotencyKey(idempotencyKey); audit.setRequestHash(hash(command));
|
||||
audit.setRequestJson(write(Map.of("command", command, "before", before, "sourceFacts", sourceFacts)));
|
||||
audit.setResultJson(write(result)); audit.setCreatedAt(now); repository.insert(audit);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 先锁原会话,再按主键锁齐计划、任务和托盘来源,阻断并发重新分配。 */
|
||||
private Scope load(String customerId, String planNo, List<InboundPlan> original, boolean lock) {
|
||||
InboundPlan selected = requireSelected(original, customerId, planNo);
|
||||
LastlegOrder source = repository.order(selected.getSourceOrderId());
|
||||
if (source == null || source.getSourceContainerBookingId() == null) throw conflict("来源柜次信息缺失");
|
||||
ReceivingContainerSession session = reconciliation.session(source.getSourceContainerBookingId(),
|
||||
selected.getContainerNo(), selected.getOverseasWarehouseId(), lock);
|
||||
List<InboundPlan> plans = lock ? reconciliation.plans(customerId, planNo, true) : original;
|
||||
if (!plans.stream().map(InboundPlan::getId).toList().equals(original.stream().map(InboundPlan::getId).toList())) {
|
||||
throw conflict("本柜入库计划已变化,请刷新后重新核对");
|
||||
}
|
||||
List<PlanState> states = new ArrayList<>();
|
||||
Map<Long, InboundPlanPallet> pallets = new LinkedHashMap<>();
|
||||
for (InboundPlan plan : plans) {
|
||||
InboundPdaTask task = lock ? repository.lockTask(plan.getCustomerId(), plan.getId())
|
||||
: repository.task(plan.getCustomerId(), plan.getId());
|
||||
List<InboundPlanLine> lines = repository.lines(plan.getId()).stream()
|
||||
.sorted(Comparator.comparing(InboundPlanLine::getId)).toList();
|
||||
List<InboundPlanPalletAllocation> allocations = repository.allocations(plan.getId()).stream()
|
||||
.sorted(Comparator.comparing(InboundPlanPalletAllocation::getId)).toList();
|
||||
if (lock) allocations = allocations.stream().map(row -> repository.lockAllocation(plan.getId(), row.getId())).toList();
|
||||
for (InboundPlanPallet pallet : repository.pallets(plan.getId()).stream()
|
||||
.sorted(Comparator.comparing(InboundPlanPallet::getId)).toList()) {
|
||||
pallets.put(pallet.getId(), lock ? repository.lockPallet(plan.getId(), pallet.getId()) : pallet);
|
||||
}
|
||||
Map<Long, List<FrozenSku>> frozen = new LinkedHashMap<>();
|
||||
for (InboundPlanLine line : lines) frozen.put(line.getId(), repository
|
||||
.frozenSkus(plan.getCustomerId(), plan.getId(), line.getId()).stream()
|
||||
.sorted(Comparator.comparing(row -> normalize(row.skuCode()))).toList());
|
||||
LastlegOrder order = repository.order(plan.getSourceOrderId());
|
||||
List<ReceivingSkuReceipt> skus = readReceipts(plan, task, allocations);
|
||||
if (!pending(plan) && order != null && "ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
|
||||
for (InboundPlanLine line : lines) frozen.put(line.getId(), skus.stream()
|
||||
.filter(row -> line.getId().equals(row.getLineId())).collect(Collectors.groupingBy(
|
||||
row -> normalize(row.getSkuCode()), TreeMap::new, Collectors.summingLong(row -> value(row.getReceivedPackages()))))
|
||||
.entrySet().stream().map(entry -> new FrozenSku(entry.getKey(), entry.getValue())).toList());
|
||||
}
|
||||
states.add(new PlanState(plan, order, task, lines, allocations, skus, frozen));
|
||||
}
|
||||
List<ReceivingFbaPalletCount> physical = session == null ? List.of() : reconciliation.physicalCounts(session.getId());
|
||||
return new Scope(session, states, pallets, physical);
|
||||
}
|
||||
|
||||
/** 单票单 SKU 的归属唯一时展示完整实盘,其余组合保留分配值供人工逐项核实。 */
|
||||
private Snapshot present(String planNo, Scope scope) {
|
||||
List<Plan> plans = scope.states().stream().map(state -> new Plan(state.plan().getCustomerId(),
|
||||
state.plan().getPlanNo(), state.order() == null ? null : state.order().getCustomerName(), state.plan().getStatus(),
|
||||
state.lines().stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum(),
|
||||
state.allocations().stream().mapToInt(row -> value(row.getPickedPackageCount())).sum())).toList();
|
||||
List<Pallet> pallets = new ArrayList<>();
|
||||
List<Receipt> receipts = new ArrayList<>();
|
||||
for (InboundPlanPallet pallet : scope.pallets().values()) {
|
||||
String groupKey = groupKey(scope, pallet);
|
||||
List<PlanState> members = scope.states().stream().filter(state -> state.lines().stream()
|
||||
.anyMatch(line -> groupKey(scope, line, state.order()).equals(groupKey))).toList();
|
||||
int attributed = scope.states().stream().flatMap(state -> state.allocations().stream())
|
||||
.filter(row -> pallet.getId().equals(row.getPalletId()))
|
||||
.mapToInt(row -> value(row.getPickedPackageCount())).sum();
|
||||
ReceivingFbaPalletCount physical = scope.physical().stream()
|
||||
.filter(row -> groupKey.equals(row.getGroupKey()) && Objects.equals(pallet.getPalletNo(), row.getPalletCode()))
|
||||
.findFirst().orElse(null);
|
||||
int counted = physical == null ? attributed : value(physical.getActualPackages());
|
||||
boolean editable = groupKey.startsWith("FBA:") && members.stream().anyMatch(state -> pending(state.plan()));
|
||||
pallets.add(new Pallet(pallet.getId(), pallet.getPalletNo(), groupKey, counted, attributed, editable));
|
||||
int identities = members.stream().mapToInt(state -> state.lines().stream()
|
||||
.filter(line -> groupKey.equals(groupKey(scope, line, state.order())))
|
||||
.mapToInt(line -> state.frozen().get(line.getId()).size()).sum()).sum();
|
||||
for (PlanState state : members) for (InboundPlanLine line : state.lines()) {
|
||||
if (!groupKey.equals(groupKey(scope, line, state.order()))) continue;
|
||||
for (FrozenSku sku : state.frozen().get(line.getId())) {
|
||||
String code = normalize(sku.skuCode());
|
||||
int allocated = state.skus().stream().filter(row -> line.getId().equals(row.getLineId())
|
||||
&& pallet.getId().equals(row.getPalletId()) && code.equals(normalize(row.getSkuCode())))
|
||||
.mapToInt(row -> value(row.getReceivedPackages())).sum();
|
||||
boolean receiptEditable = pending(state.plan()) && groupKey.startsWith("FBA:");
|
||||
int received = identities == 1 && receiptEditable ? counted : allocated;
|
||||
String product = state.skus().stream().filter(row -> code.equals(normalize(row.getSkuCode())))
|
||||
.map(ReceivingSkuReceipt::getProductName).filter(Objects::nonNull).findFirst().orElse(null);
|
||||
receipts.add(new Receipt(state.plan().getCustomerId(), state.plan().getPlanNo(), line.getId(),
|
||||
line.getInboundCode(), pallet.getId(), pallet.getPalletNo(), code, product,
|
||||
Math.toIntExact(sku.packages()), received, receiptEditable));
|
||||
}
|
||||
}
|
||||
}
|
||||
String blocked = blocker(scope, pallets, receipts);
|
||||
return new Snapshot(planNo, scope.states().get(0).plan().getContainerNo(), hash(scope),
|
||||
plans.stream().mapToInt(Plan::expectedPackageCount).sum(),
|
||||
pallets.stream().mapToInt(Pallet::countedPackageCount).sum(),
|
||||
plans.stream().mapToInt(Plan::attributedPackageCount).sum(), blocked == null, blocked,
|
||||
plans, pallets, receipts);
|
||||
}
|
||||
|
||||
/** 证据缺失明确提示,避免把未知清点、未知 SKU 或其他作业来源默认为零。 */
|
||||
private String blocker(Scope scope, List<Pallet> pallets, List<Receipt> receipts) {
|
||||
if (scope.session() == null || scope.session().getCompletedAt() == null
|
||||
|| !List.of("COMPLETED", "COMPLETED_WITH_DIFFERENCE").contains(scope.session().getStatus())) {
|
||||
return "本柜现场收货尚未结束,请先完成现场收货";
|
||||
}
|
||||
if (scope.states().stream().noneMatch(state -> pending(state.plan()))) return "本柜收货异常已经核对完成";
|
||||
for (PlanState state : scope.states()) {
|
||||
if (!List.of("PENDING_RECONCILIATION", "PARTIAL_INBOUND", "COMPLETED").contains(state.plan().getStatus())) {
|
||||
return "本柜仍有未结束的入库作业,请先完成现场收货";
|
||||
}
|
||||
if (!pending(state.plan())) continue;
|
||||
if (state.order() != null && "ONE_CLICK_DROPSHIP".equals(state.order().getOrderType())) {
|
||||
return "一件代发须按冻结来源整托核对,请先核实原托盘及实物一致性";
|
||||
}
|
||||
if (state.task() == null || !"PENDING_RECONCILIATION".equals(state.task().getStatus())
|
||||
|| state.task().getCompletedAt() == null || state.task().getUnloadPhotoUploadedAt() == null
|
||||
|| state.task().getUnloadPhotoFileIds() == null || "[]".equals(state.task().getUnloadPhotoFileIds())) {
|
||||
return "待核对入库单缺少现场结束或卸柜凭证";
|
||||
}
|
||||
if (state.lines().isEmpty() || state.frozen().values().stream().anyMatch(rows -> rows.isEmpty()
|
||||
|| rows.stream().anyMatch(row -> normalize(row.skuCode()).isEmpty() || row.packages() < 0))) {
|
||||
return "冻结商品资料缺失,请先核实每票货物的 SKU 归属";
|
||||
}
|
||||
if (state.lines().stream().anyMatch(line -> value(line.getRequiredPackageCount()) <= 0)) {
|
||||
return "原应收箱数缺失,请先核实来源入库资料";
|
||||
}
|
||||
if (state.lines().stream().anyMatch(line -> state.frozen().get(line.getId()).stream()
|
||||
.mapToLong(FrozenSku::packages).sum() != value(line.getRequiredPackageCount()))) {
|
||||
return "冻结 SKU 箱数与原应收不一致,请先核实来源商品资料";
|
||||
}
|
||||
}
|
||||
for (ReceivingFbaPalletCount physical : scope.physical()) {
|
||||
if (pallets.stream().noneMatch(pallet -> Objects.equals(pallet.palletNo(), physical.getPalletCode())
|
||||
&& Objects.equals(pallet.groupKey(), physical.getGroupKey()))) {
|
||||
return "存在未关联计划托盘的现场清点记录,请先核实托盘归属";
|
||||
}
|
||||
}
|
||||
if (pallets.isEmpty() || receipts.isEmpty()) return "收货托盘或商品明细缺失,请先核实来源";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 请求必须覆盖当前全部明细,每托数量守恒,已完成票据保持原库存事实。 */
|
||||
private void validate(Snapshot before, Confirm request) {
|
||||
Map<Long, Count> counts = new LinkedHashMap<>();
|
||||
for (Count row : request.pallets()) {
|
||||
if (row.palletId() == null || row.countedPackageCount() == null || row.countedPackageCount() < 0
|
||||
|| counts.put(row.palletId(), row) != null) {
|
||||
throw conflict("逐托清点数量必须为 0 至 66 的整数且不能重复");
|
||||
}
|
||||
}
|
||||
Map<String, Quantity> quantities = new LinkedHashMap<>();
|
||||
for (Quantity row : request.receipts()) {
|
||||
if (row.receivedPackageCount() == null || row.receivedPackageCount() < 0
|
||||
|| quantities.put(key(row.customerId(), row.planNo(), row.lineId(), row.palletId(), row.skuCode()), row) != null) {
|
||||
throw conflict("逐票 SKU 数量必须为 0 至 66 的整数且不能重复");
|
||||
}
|
||||
}
|
||||
if (counts.size() != before.pallets().size() || quantities.size() != before.receipts().size()) {
|
||||
throw conflict("核对明细不完整,请刷新后逐项核对");
|
||||
}
|
||||
for (Receipt row : before.receipts()) {
|
||||
Quantity submitted = quantities.get(key(row.customerId(), row.planNo(), row.lineId(), row.palletId(), row.skuCode()));
|
||||
if (submitted == null || (row.editable() && submitted.receivedPackageCount() > 66)
|
||||
|| (!row.editable() && submitted.receivedPackageCount() != row.receivedPackageCount())) {
|
||||
throw conflict("收货归属已变化或已入库票据被修改,请刷新后重新核对");
|
||||
}
|
||||
}
|
||||
for (Pallet pallet : before.pallets()) {
|
||||
Count count = counts.get(pallet.palletId());
|
||||
if (count == null || (pallet.editable() && count.countedPackageCount() > 66)
|
||||
|| (!pallet.editable() && count.countedPackageCount() != pallet.countedPackageCount())) {
|
||||
throw conflict("已完成的托盘清点不能在本次异常确认中修改");
|
||||
}
|
||||
int total = request.receipts().stream().filter(row -> pallet.palletId().equals(row.palletId()))
|
||||
.mapToInt(Quantity::receivedPackageCount).sum();
|
||||
if (total != count.countedPackageCount()) throw conflict("托盘 " + pallet.palletNo() + " 的 SKU 归属合计必须等于核实清点箱数");
|
||||
}
|
||||
}
|
||||
|
||||
/** 现场原始输入已有收货日记,本次审计另保留更新前快照和明确更正原因。 */
|
||||
private void applyPhysical(Scope scope, Snapshot before, Confirm request, LocalDateTime now) {
|
||||
for (Pallet pallet : before.pallets()) {
|
||||
if (!pallet.editable() || !pallet.groupKey().startsWith("FBA:")) continue;
|
||||
int count = request.pallets().stream().filter(row -> row.palletId().equals(pallet.palletId()))
|
||||
.findFirst().orElseThrow().countedPackageCount();
|
||||
ReceivingFbaPalletCount physical = scope.physical().stream().filter(row -> row.getGroupKey().equals(pallet.groupKey())
|
||||
&& row.getPalletCode().equals(pallet.palletNo())).findFirst().orElse(null);
|
||||
boolean existing = physical != null;
|
||||
if (!existing) {
|
||||
physical = new ReceivingFbaPalletCount(); physical.setId(IdWorker.getId());
|
||||
physical.setSessionId(scope.session().getId()); physical.setGroupKey(pallet.groupKey());
|
||||
physical.setPalletCode(pallet.palletNo()); physical.setCreatedAt(now);
|
||||
}
|
||||
physical.setActualPackages(count); physical.setUpdatedAt(now);
|
||||
reconciliation.savePhysical(physical, existing);
|
||||
}
|
||||
}
|
||||
|
||||
/** 将已明确确认的逐 SKU 事实和逐托合计同时写入,原申报分配保持可追溯。 */
|
||||
private void applyReceipts(Scope scope, PlanState state, Confirm request, LocalDateTime now) {
|
||||
List<Quantity> rows = request.receipts().stream().filter(row -> row.planNo().equals(state.plan().getPlanNo())
|
||||
&& row.customerId().equals(state.plan().getCustomerId())).toList();
|
||||
List<ReceivingSkuReceipt> confirmed = new ArrayList<>();
|
||||
for (InboundPlanLine line : state.lines()) for (InboundPlanPallet pallet : scope.pallets().values()) {
|
||||
List<Quantity> linePallet = rows.stream().filter(row -> row.lineId().equals(line.getId())
|
||||
&& row.palletId().equals(pallet.getId())).toList();
|
||||
if (linePallet.isEmpty()) continue;
|
||||
int packages = linePallet.stream().mapToInt(Quantity::receivedPackageCount).sum();
|
||||
if (packages > 0 && (pallet.getWarehousePalletId() == null || pallet.getWarehouseLocationId() == null
|
||||
|| pallet.getWarehouseResourceLockId() == null)) throw conflict("托盘 " + pallet.getPalletNo() + " 缺少实际仓储绑定");
|
||||
InboundPlanPalletAllocation allocation = state.allocations().stream()
|
||||
.filter(row -> line.getId().equals(row.getLineId()) && pallet.getId().equals(row.getPalletId())).findFirst().orElse(null);
|
||||
if (allocation == null && packages == 0 && !state.plan().getId().equals(pallet.getPlanId())) continue;
|
||||
if (allocation == null) {
|
||||
allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId());
|
||||
allocation.setCustomerId(state.plan().getCustomerId()); allocation.setPlanId(state.plan().getId());
|
||||
allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setPlannedPackageCount(0);
|
||||
allocation.setAllocatedVolumeCbm(BigDecimal.ZERO); allocation.setActualVolumeCbm(BigDecimal.ZERO);
|
||||
allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0); allocation.setStatus("PICKED");
|
||||
allocation.setVersion(0); repository.insert(allocation);
|
||||
}
|
||||
allocation.setCountedPackageCount(packages); allocation.setPickedPackageCount(packages);
|
||||
allocation.setActualVolumeCbm(Objects.requireNonNullElse(line.getVolumeCbm(), BigDecimal.ZERO)
|
||||
.divide(BigDecimal.valueOf(line.getRequiredPackageCount()), 8, RoundingMode.HALF_UP)
|
||||
.multiply(BigDecimal.valueOf(packages)).setScale(4, RoundingMode.HALF_UP));
|
||||
allocation.setStatus("PICKED"); allocation.setVersion(value(allocation.getVersion()) + 1); repository.update(allocation);
|
||||
for (Quantity row : linePallet) {
|
||||
if (row.receivedPackageCount() == 0) continue;
|
||||
ReceivingSkuReceipt sku = new ReceivingSkuReceipt(); sku.setId(IdWorker.getId());
|
||||
sku.setCustomerId(state.plan().getCustomerId()); sku.setSessionId(scope.session().getId());
|
||||
sku.setPlanId(state.plan().getId()); sku.setLineId(line.getId()); sku.setPalletId(pallet.getId());
|
||||
sku.setOrderId(line.getOrderId()); sku.setInboundCode(line.getInboundCode()); sku.setSkuCode(normalize(row.skuCode()));
|
||||
sku.setProductName(state.skus().stream().filter(old -> normalize(old.getSkuCode()).equals(sku.getSkuCode()))
|
||||
.map(ReceivingSkuReceipt::getProductName).filter(Objects::nonNull).findFirst().orElse(null));
|
||||
sku.setReceivedPackages(row.receivedPackageCount()); sku.setCreatedAt(state.task().getCompletedAt());
|
||||
sku.setUpdatedAt(now); sku.setVersion(0); confirmed.add(sku);
|
||||
}
|
||||
}
|
||||
reconciliation.replaceSkuReceipts(state.plan().getCustomerId(), state.plan().getId(), confirmed);
|
||||
for (InboundPlanCarton carton : repository.cartons(state.plan().getId())) {
|
||||
if ("ON_PALLET".equals(carton.getStatus())) {
|
||||
carton.setStatus("AVAILABLE"); carton.setReceivedAt(state.task().getCompletedAt());
|
||||
carton.setUpdatedAt(now); carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 后台核对复用已入库凭证和逐箱扫描身份,不按申报顺序猜测普通地址货物。 */
|
||||
private List<ReceivingSkuReceipt> readReceipts(InboundPlan plan, InboundPdaTask task,
|
||||
List<InboundPlanPalletAllocation> allocations) {
|
||||
List<ReceivingSkuReceipt> rows = repository.skuReceipts(plan.getCustomerId(), plan.getId());
|
||||
if (!rows.isEmpty()) return rows;
|
||||
List<ReceivingSkuReceipt> restored = new ArrayList<>();
|
||||
if (!pending(plan) && task != null) {
|
||||
for (InboundStockReceipt stock : reconciliation.stockReceipts(plan.getCustomerId(), task.getId())) {
|
||||
InboundPlanPalletAllocation allocation = allocations.stream()
|
||||
.filter(row -> Objects.equals(row.getId(), stock.getAllocationId())).findFirst().orElse(null);
|
||||
if (allocation == null) throw conflict("已入库凭证缺少原托盘来源,请核实库存事实");
|
||||
ReceivingSkuReceipt row = new ReceivingSkuReceipt(); row.setLineId(stock.getLineId());
|
||||
row.setPalletId(allocation.getPalletId()); row.setSkuCode(stock.getSkuCode());
|
||||
row.setProductName(stock.getProductNameZh()); row.setReceivedPackages(Math.toIntExact(stock.getReceivedPackages()));
|
||||
restored.add(row);
|
||||
}
|
||||
} else {
|
||||
for (InboundPlanCarton carton : repository.cartons(plan.getId())) {
|
||||
if (!"ON_PALLET".equals(carton.getStatus())) continue;
|
||||
ReceivingSkuReceipt row = new ReceivingSkuReceipt(); row.setLineId(carton.getLineId());
|
||||
row.setPalletId(carton.getPlanPalletId()); row.setSkuCode(carton.getSkuCode()); row.setReceivedPackages(1);
|
||||
restored.add(row);
|
||||
}
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
|
||||
/** SKU 短少单独判定,不能被另一商品超收抵消。 */
|
||||
private boolean hasShortage(PlanState state, List<Quantity> rows) {
|
||||
for (InboundPlanLine line : state.lines()) for (FrozenSku sku : state.frozen().get(line.getId())) {
|
||||
long actual = rows.stream().filter(row -> row.planNo().equals(state.plan().getPlanNo())
|
||||
&& row.customerId().equals(state.plan().getCustomerId()) && row.lineId().equals(line.getId())
|
||||
&& normalize(row.skuCode()).equals(normalize(sku.skuCode())))
|
||||
.mapToLong(Quantity::receivedPackageCount).sum();
|
||||
if (actual < sku.packages()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 托盘所属目的组来自原计划,实物记录不能跨目的仓或来源柜次归属。 */
|
||||
private String groupKey(Scope scope, InboundPlanPallet pallet) {
|
||||
PlanState owner = scope.states().stream().filter(state -> state.plan().getId().equals(pallet.getPlanId())).findFirst().orElseThrow();
|
||||
if (owner.order() != null && "ONE_CLICK_DROPSHIP".equals(owner.order().getOrderType())) {
|
||||
return "DROPSHIP:" + owner.plan().getContainerNo() + ":" + owner.plan().getPlanNo();
|
||||
}
|
||||
InboundPlanLine line = owner.lines().stream().filter(row -> owner.allocations().stream()
|
||||
.anyMatch(a -> a.getLineId().equals(row.getId()) && a.getPalletId().equals(pallet.getId()))).findFirst().orElse(null);
|
||||
if (line != null) return groupKey(scope, line, owner.order());
|
||||
String destination = normalize(pallet.getDestinationWarehouseCode());
|
||||
return ("FBA".equals(normalize(pallet.getDestinationType())) ? "FBA:" : "OVERSEAS:")
|
||||
+ owner.plan().getContainerNo() + ":" + destination;
|
||||
}
|
||||
|
||||
/** 普通 FBA 共用目的仓组,私人地址按原目的身份保留独立归属。 */
|
||||
private String groupKey(Scope scope, InboundPlanLine line, LastlegOrder order) {
|
||||
if (order != null && "ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
|
||||
String planNo = scope.states().stream().filter(state -> state.lines().contains(line))
|
||||
.findFirst().orElseThrow().plan().getPlanNo();
|
||||
return "DROPSHIP:" + scope.states().get(0).plan().getContainerNo() + ":" + planNo;
|
||||
}
|
||||
String type = normalize(line.getDestinationType());
|
||||
if (type.isEmpty() && order != null) type = normalize(order.getDestinationType());
|
||||
if (type.isEmpty()) type = normalize(line.getFbaWarehouseCode()).isEmpty() ? "OVERSEAS" : "FBA";
|
||||
String destination = "FBA".equals(type) ? normalize(line.getFbaWarehouseCode())
|
||||
: normalize(Objects.toString(line.getDestinationWarehouseId(), line.getDestinationWarehouseCode()));
|
||||
return type + ":" + scope.states().get(0).plan().getContainerNo() + ":" + destination;
|
||||
}
|
||||
|
||||
/** 先校验请求客户与计划,再允许展开该计划所属的同柜范围。 */
|
||||
private InboundPlan requireSelected(List<InboundPlan> plans, String customerId, String planNo) {
|
||||
return plans.stream().filter(plan -> customerId.equals(plan.getCustomerId()) && planNo.equals(plan.getPlanNo()))
|
||||
.findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "入库单不存在或来源柜次信息缺失"));
|
||||
}
|
||||
/** 仅待核对票据可以更新收货归属。 */
|
||||
private static boolean pending(InboundPlan plan) { return "PENDING_RECONCILIATION".equals(plan.getStatus()); }
|
||||
/** 空计数用于读取汇总,提交数量必须另行显式校验。 */
|
||||
private static int value(Integer value) { return value == null ? 0 : value; }
|
||||
/** SKU 和目的仓采用与现场分组一致的标准化形式。 */
|
||||
private static String normalize(String value) { return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); }
|
||||
/** 同时限定客户、票、明细、托盘和 SKU,防止跨来源替换。 */
|
||||
private static String key(String customer, String plan, Long line, Long pallet, String sku) {
|
||||
return customer + "/" + plan + "/" + line + "/" + pallet + "/" + normalize(sku);
|
||||
}
|
||||
/** 核对条件变化作为业务冲突返回,事务整体撤回。 */
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
/** 全部版本及现场数量参与摘要,过期页面不能覆盖新的事实。 */
|
||||
private String hash(Object value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
|
||||
}
|
||||
/** 持久化原始事实、操作原因和回执,保证可追溯。 */
|
||||
private String write(Object value) {
|
||||
try { return objectMapper.writeValueAsString(value); }
|
||||
catch (JsonProcessingException exception) { throw new IllegalStateException("异常核对证据序列化失败", exception); }
|
||||
}
|
||||
/** 同键重放直接读取原回执,避免再次记库存。 */
|
||||
private <T> T read(String json, Class<T> type) {
|
||||
try { return objectMapper.readValue(json, type); }
|
||||
catch (JsonProcessingException exception) { throw new IllegalStateException("异常核对回执读取失败", exception); }
|
||||
}
|
||||
private record PlanState(InboundPlan plan, LastlegOrder order, InboundPdaTask task,
|
||||
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
|
||||
List<ReceivingSkuReceipt> skus, Map<Long, List<FrozenSku>> frozen) { }
|
||||
private record Scope(ReceivingContainerSession session, List<PlanState> states,
|
||||
Map<Long, InboundPlanPallet> pallets, List<ReceivingFbaPalletCount> physical) { }
|
||||
}
|
||||
+97
@@ -48,6 +48,7 @@ public class InboundStockReceiptService {
|
||||
@Resource private InboundStockReceiptMapper receipts;
|
||||
@Resource private ReceivingSkuReceiptMapper receivingSkuReceipts;
|
||||
@Resource private PdaWorkflowRepository workflowRepository;
|
||||
@Resource private com.baiyue.lastleg.inboundplan.pda.repository.InboundReceivingReconciliationRepository reconciliationRepository;
|
||||
@Resource private InventoryCommandService inventory;
|
||||
@Resource private WarehouseResourceGateway warehouseGateway;
|
||||
@Resource private DropshipOrderService dropshipOrders;
|
||||
@@ -61,6 +62,99 @@ public class InboundStockReceiptService {
|
||||
stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false);
|
||||
}
|
||||
|
||||
/** 平台核对异常后按明确的逐托、逐 SKU 实收入库,保留原申报和现场收货时间。 */
|
||||
@Transactional
|
||||
public void stockInReconciled(String customerId, InboundPlan plan, InboundPdaTask task,
|
||||
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
|
||||
List<InboundPlanPallet> pallets, LocalDateTime receivedAt) {
|
||||
if (lines.isEmpty() || receivedAt == null) throw conflict("异常收货必须保留入库明细和现场收货时间");
|
||||
Map<Long, LastlegOrder> orderById = reconciliationRepository.orders(lines.stream()
|
||||
.map(InboundPlanLine::getOrderId).collect(Collectors.toSet())).stream()
|
||||
.filter(order -> customerId.equals(order.getCustomerId()))
|
||||
.collect(Collectors.toMap(LastlegOrder::getId, order -> order));
|
||||
Map<Long, InboundPlanPallet> palletById = pallets.stream()
|
||||
.collect(Collectors.toMap(InboundPlanPallet::getId, pallet -> pallet));
|
||||
Map<Long, InboundPlanPalletAllocation> allocationById = allocations.stream()
|
||||
.collect(Collectors.toMap(InboundPlanPalletAllocation::getId, allocation -> allocation));
|
||||
Map<Long, List<ReceivingSkuReceipt>> exactByLine = reconciliationRepository.skuReceipts(customerId, plan.getId())
|
||||
.stream().collect(Collectors.groupingBy(ReceivingSkuReceipt::getLineId));
|
||||
Set<Long> lineIds = lines.stream().map(InboundPlanLine::getId).collect(Collectors.toSet());
|
||||
if (!lineIds.containsAll(exactByLine.keySet()) || allocations.stream()
|
||||
.anyMatch(allocation -> !lineIds.contains(allocation.getLineId())
|
||||
|| allocation.getPickedPackageCount() == null || allocation.getCountedPackageCount() == null
|
||||
|| value(allocation.getPickedPackageCount()) < 0
|
||||
|| !Objects.equals(allocation.getPickedPackageCount(), allocation.getCountedPackageCount()))) {
|
||||
throw conflict("异常收货的逐票、清点和收货数量不一致");
|
||||
}
|
||||
List<ReconciledReceipt> confirmed = new java.util.ArrayList<>();
|
||||
Map<ReceiptKey, Long> expectedReceipts = new java.util.HashMap<>();
|
||||
Map<Long, Boolean> shortages = new java.util.HashMap<>();
|
||||
for (InboundPlanLine line : lines) {
|
||||
LastlegOrder order = orderById.get(line.getOrderId());
|
||||
if (order == null || "ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
|
||||
throw conflict("异常收货只支持归属明确的普通入库订单");
|
||||
}
|
||||
Map<String, SkuRemainder> frozenSkus = skuRemainders(order.getId(), value(line.getRequiredPackageCount()))
|
||||
.stream().collect(Collectors.toMap(sku -> sku.skuCode.toUpperCase(java.util.Locale.ROOT),
|
||||
sku -> sku, (first, next) -> {
|
||||
first.remaining = Math.addExact(first.remaining, next.remaining);
|
||||
return first;
|
||||
}));
|
||||
Map<Long, InboundPlanPalletAllocation> lineAllocations = allocations.stream()
|
||||
.filter(allocation -> Objects.equals(allocation.getLineId(), line.getId()))
|
||||
.collect(Collectors.toMap(InboundPlanPalletAllocation::getPalletId, allocation -> allocation));
|
||||
Map<Long, Long> packagesByPallet = new java.util.HashMap<>();
|
||||
Map<String, Long> packagesBySku = new java.util.HashMap<>();
|
||||
for (ReceivingSkuReceipt exact : exactByLine.getOrDefault(line.getId(), List.of())) {
|
||||
String skuCode = required(exact.getSkuCode(), "异常收货必须明确实收 SKU").toUpperCase(java.util.Locale.ROOT);
|
||||
SkuRemainder frozen = frozenSkus.get(skuCode);
|
||||
InboundPlanPalletAllocation allocation = lineAllocations.get(exact.getPalletId());
|
||||
long packages = value(exact.getReceivedPackages());
|
||||
if (frozen == null || allocation == null || packages <= 0) {
|
||||
throw conflict("异常收货必须使用原申报 SKU、真实托盘和正数箱数");
|
||||
}
|
||||
ReceiptKey key = new ReceiptKey(allocation.getId(), skuCode);
|
||||
if (expectedReceipts.putIfAbsent(key, packages) != null) throw conflict("同托同 SKU 的实收记录重复");
|
||||
packagesByPallet.merge(exact.getPalletId(), packages, Math::addExact);
|
||||
packagesBySku.merge(skuCode, packages, Math::addExact);
|
||||
confirmed.add(new ReconciledReceipt(line, allocation, requirePallet(palletById, allocation), order,
|
||||
new SkuSnapshot(frozen.skuCode, frozen.productNameZh, frozen.productNameEn, packages)));
|
||||
}
|
||||
if (lineAllocations.values().stream().anyMatch(allocation ->
|
||||
value(allocation.getPickedPackageCount()) != packagesByPallet.getOrDefault(allocation.getPalletId(), 0L))) {
|
||||
throw conflict("异常收货的逐托 SKU 箱数必须等于该托确认实收箱数");
|
||||
}
|
||||
boolean shortage = frozenSkus.entrySet().stream()
|
||||
.anyMatch(entry -> packagesBySku.getOrDefault(entry.getKey(), 0L) < entry.getValue().remaining);
|
||||
shortages.merge(order.getId(), shortage, (previous, current) -> previous || current);
|
||||
}
|
||||
// 重试只能重放同一份逐托 SKU 凭证,已入账数量发生变化时禁止二次入账或静默跳过。
|
||||
List<InboundStockReceipt> existing = reconciliationRepository.stockReceipts(customerId, task.getId());
|
||||
Map<ReceiptKey, Long> existingReceipts = new java.util.HashMap<>();
|
||||
for (InboundStockReceipt receipt : existing) {
|
||||
InboundPlanPalletAllocation allocation = allocationById.get(receipt.getAllocationId());
|
||||
ReceiptKey key = new ReceiptKey(receipt.getAllocationId(),
|
||||
required(receipt.getSkuCode(), "原库存凭证缺少 SKU").toUpperCase(java.util.Locale.ROOT));
|
||||
if (allocation == null || !Objects.equals(receipt.getLineId(), allocation.getLineId())
|
||||
|| existingReceipts.putIfAbsent(key, receipt.getReceivedPackages()) != null) {
|
||||
throw conflict("原库存凭证与确认实收归属不一致");
|
||||
}
|
||||
}
|
||||
if (!existing.isEmpty() && !existingReceipts.equals(expectedReceipts)) {
|
||||
throw conflict("原库存凭证与确认实收数量不一致,不能重复入账");
|
||||
}
|
||||
if (existing.isEmpty()) {
|
||||
for (ReconciledReceipt receipt : confirmed) {
|
||||
persist(customerId, plan, task, receipt.line(), receipt.allocation(), receipt.pallet(), receipt.order(),
|
||||
receipt.sku(), receivedAt);
|
||||
}
|
||||
}
|
||||
// 零实收不产生零数量库存或计租凭证;逐 SKU 短少独立决定每票完成状态。
|
||||
for (LastlegOrder order : orderById.values()) {
|
||||
completeOrders(customerId, plan, Map.of(order.getId(), order), receivedAt, shortages.get(order.getId()));
|
||||
}
|
||||
}
|
||||
|
||||
/** 按逐 SKU 实收生成凭证;普通海外仓货物以已扫描箱码和实托归属为准。 */
|
||||
@Transactional
|
||||
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
|
||||
@@ -435,5 +529,8 @@ public class InboundStockReceiptService {
|
||||
}
|
||||
}
|
||||
|
||||
private record ReceiptKey(Long allocationId, String skuCode) { }
|
||||
private record ReconciledReceipt(InboundPlanLine line, InboundPlanPalletAllocation allocation,
|
||||
InboundPlanPallet pallet, LastlegOrder order, SkuSnapshot sku) { }
|
||||
private record SkuSnapshot(String skuCode, String productNameZh, String productNameEn, long packages) {}
|
||||
}
|
||||
|
||||
+14
-5
@@ -139,7 +139,7 @@ public class ReceivingContainerService {
|
||||
Set<String> reservedByOthers = candidateRepository.activeSessions().stream()
|
||||
.filter(session -> !operatorLockKey.equals(session.getActiveOperatorKey()))
|
||||
.map(ReceivingContainerSession::getActiveContainerKey).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
return grouped.entrySet().stream()
|
||||
List<ContainerReadiness> visible = grouped.entrySet().stream()
|
||||
.map(entry -> {
|
||||
var localLines = entry.getValue().stream().flatMap(order ->
|
||||
linesByOrder.getOrDefault(order.getId(), List.of()).stream()).toList();
|
||||
@@ -155,7 +155,11 @@ public class ReceivingContainerService {
|
||||
.thenComparing(ContainerReadiness::plannedArrivalAt,
|
||||
Comparator.nullsLast(Comparator.naturalOrder()))
|
||||
.thenComparing(ContainerReadiness::sourceContainerBookingId))
|
||||
.map(value -> candidate(value, current)).toList();
|
||||
.toList();
|
||||
Map<String, String> cabinetNumbers = candidateRepository.cabinetNumbers(
|
||||
visible.stream().map(ContainerReadiness::sourceContainerBookingId).toList());
|
||||
return visible.stream().map(value -> candidate(value, current,
|
||||
cabinetNumbers.get(value.sourceContainerBookingId()))).toList();
|
||||
}
|
||||
|
||||
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo,
|
||||
@@ -783,8 +787,9 @@ public class ReceivingContainerService {
|
||||
blockReason, arrival, factHash, totals);
|
||||
}
|
||||
|
||||
/** 候选展示批量读取的正式柜次号,继续以稳定柜身份执行接柜门禁。 */
|
||||
private ReceivingContainerResponses.Candidate candidate(ContainerReadiness value,
|
||||
ReceivingContainerSession current) {
|
||||
ReceivingContainerSession current, String cabinetNo) {
|
||||
boolean active = current != null && value.containerNo().equals(current.getContainerNo());
|
||||
Totals totals = value.totals();
|
||||
return new ReceivingContainerResponses.Candidate(value.sourceContainerBookingId(), value.containerNo(),
|
||||
@@ -793,7 +798,7 @@ public class ReceivingContainerService {
|
||||
value.missingInboundCodeCount(), value.planCount(), value.plannedInboundCodeCount(),
|
||||
value.pendingPlanCount(), totals.destinationCodes(), totals.plannedPalletCount(), totals.total(),
|
||||
totals.received(), value.operable(), value.allPlansReady(), value.blockReason(),
|
||||
value.plannedArrivalAt(), active);
|
||||
value.plannedArrivalAt(), active, cabinetNo);
|
||||
}
|
||||
|
||||
private ReceivingContainerSession requireSigned(String operatorKey) {
|
||||
@@ -1083,18 +1088,22 @@ public class ReceivingContainerService {
|
||||
private record DropshipPalletRef(InboundRef ref, InboundPlanPallet pallet,
|
||||
InboundPlanPalletAllocation allocation) { }
|
||||
|
||||
/** 当前柜摘要从本地冻结快照补充柜次号,覆盖详情、签收、恢复及完成响应。 */
|
||||
private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
|
||||
List<ReceivingContainerResponses.Inbound> inbounds = refs(
|
||||
session.getSourceContainerBookingId(), session.getContainerNo()).stream()
|
||||
.map(this::inbound).toList();
|
||||
int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum();
|
||||
int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum();
|
||||
String bookingId = session.getSourceContainerBookingId();
|
||||
String cabinetNo = bookingId == null ? null
|
||||
: candidateRepository.cabinetNumbers(List.of(bookingId)).get(bookingId);
|
||||
return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(),
|
||||
session.getOverseasWarehouseCode(), session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(),
|
||||
value(session.getVersion()), signPhotos(session), domesticLoadingPhotos(session),
|
||||
session.getPreparedAt(), session.getSignedAt(),
|
||||
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received),
|
||||
hasFbaOverReceipt(session, containerPlans), inbounds);
|
||||
hasFbaOverReceipt(session, containerPlans), inbounds, cabinetNo);
|
||||
}
|
||||
|
||||
private List<ReceivingContainerResponses.DomesticLoadingPhoto> domesticLoadingPhotos(
|
||||
|
||||
+2
-1
@@ -44,11 +44,12 @@ public class MerchantLocalLooseboardController {
|
||||
return ApiResponse.success(service.page(customer(user), query));
|
||||
}
|
||||
|
||||
/** 客户详情使用与列表一致的运输进度。 */
|
||||
@GetMapping("/{orderNo}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_LIST + "')")
|
||||
public ApiResponse<LocalLooseboardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String orderNo) {
|
||||
return ApiResponse.success(service.detail(customer(user), orderNo));
|
||||
return ApiResponse.success(service.merchantDetail(customer(user), orderNo));
|
||||
}
|
||||
|
||||
@PutMapping("/{orderNo}")
|
||||
|
||||
+36
@@ -8,6 +8,42 @@ import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface LocalLooseboardOrderMapper extends BaseMapper<LocalLooseboardOrder> {
|
||||
// 客户状态按正式排车与取货事实投影;不改变收货、测量等内部状态门禁。
|
||||
String DELIVERY = " FROM ll_inbound_plan p"
|
||||
+ " JOIN ll_inbound_plan_pallet pp ON pp.plan_id=p.id AND pp.customer_id=p.customer_id"
|
||||
+ " JOIN ll_dispatch_item i ON i.plan_pallet_id=pp.id AND i.customer_id=p.customer_id"
|
||||
+ " JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id AND d.status='ACTIVE'"
|
||||
+ " WHERE p.source_order_id=ll_local_looseboard_order.lastleg_order_id"
|
||||
+ " AND p.customer_id=ll_local_looseboard_order.customer_id";
|
||||
String PICKUP = " FROM ll_warehouse_transfer_order w"
|
||||
// 两表客户标识使用各自排序规则,按完整字节身份关联,512 字节覆盖 VARCHAR(128) 的 utf8mb4 上限。
|
||||
+ " JOIN ll_fleet_pickup_task t ON t.transfer_order_id=w.id"
|
||||
+ " AND CAST(t.customer_id AS BINARY(512))=CAST(w.customer_id AS BINARY(512))"
|
||||
+ " JOIN ll_fleet_trip f ON f.trip_no=t.trip_no AND f.status IN ('PLANNED','IN_TRANSIT')"
|
||||
+ " WHERE w.customer_id=ll_local_looseboard_order.customer_id"
|
||||
+ " AND w.transfer_no=ll_local_looseboard_order.transfer_no";
|
||||
String MERCHANT_STATUS = "(CASE"
|
||||
+ " WHEN ll_local_looseboard_order.status IN ('FUNDING_PENDING','FUNDING_FAILED','PENDING_ACCEPTANCE','COMPLETED','CANCELLED')"
|
||||
+ " THEN ll_local_looseboard_order.status"
|
||||
+ " WHEN ll_local_looseboard_order.status='IN_TRANSIT' OR EXISTS (SELECT 1" + DELIVERY
|
||||
+ " AND d.stage IN ('DELIVERING','DELIVERED')) THEN 'IN_TRANSIT'"
|
||||
+ " WHEN EXISTS (SELECT 1" + PICKUP
|
||||
+ " AND (t.status='PICKED' OR (t.status='ASSIGNED' AND f.status='IN_TRANSIT'))) THEN 'PICKING_UP'"
|
||||
+ " WHEN EXISTS (SELECT 1" + DELIVERY + ") OR EXISTS (SELECT 1" + PICKUP
|
||||
+ " AND t.status='ASSIGNED') THEN 'DISPATCHED'"
|
||||
+ " WHEN ll_local_looseboard_order.status='DISPATCHED' THEN 'DISPATCHED'"
|
||||
+ " ELSE 'PENDING_DISPATCH' END)";
|
||||
|
||||
/** 批量读取同客户散板的运输状态,与分页筛选使用同一表达式。 */
|
||||
@Select("<script>SELECT id," + MERCHANT_STATUS + " AS status FROM ll_local_looseboard_order"
|
||||
+ " WHERE customer_id=#{customerId} AND id IN"
|
||||
+ " <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
|
||||
java.util.List<MerchantStatus> merchantStatuses(@Param("customerId") String customerId,
|
||||
@Param("ids") java.util.List<Long> ids);
|
||||
|
||||
/** 客户列表与详情共享的只读状态。 */
|
||||
record MerchantStatus(Long id, String status) { }
|
||||
|
||||
@Select("SELECT COUNT(*) FROM ll_local_looseboard_order WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} AND lastleg_order_id IS NOT NULL")
|
||||
int linkedReceiving(@Param("customerId") String customerId,@Param("transferNo") String transferNo);
|
||||
@org.apache.ibatis.annotations.Update("UPDATE ll_local_looseboard_order SET status='PENDING_DISPATCH',updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} AND status='WAITING_INBOUND'")
|
||||
|
||||
+19
-2
@@ -33,18 +33,35 @@ public class LocalLooseboardRepository {
|
||||
public LocalLooseboardOrder lockNo(String customer, String number) { return orders.lockNo(customer, number); }
|
||||
/** 在客户范围内组合路线、状态与取货日期筛选并返回分页需求。 */
|
||||
public Page<LocalLooseboardOrder> page(LocalLooseboardRequests.Query query) {
|
||||
return orders.selectPage(Page.of(query.page(), query.size()), queryWrapper(query, false));
|
||||
}
|
||||
/** 客户按运输状态筛选后分页,确保总数和返回记录使用相同口径。 */
|
||||
public Page<LocalLooseboardOrder> merchantPage(LocalLooseboardRequests.Query query) {
|
||||
return orders.selectPage(Page.of(query.page(), query.size()), queryWrapper(query, true));
|
||||
}
|
||||
/** 按认证客户批量装配状态,空页不查询排车事实。 */
|
||||
public java.util.Map<Long, String> merchantStatuses(String customer, List<Long> ids) {
|
||||
if (ids.isEmpty()) return java.util.Map.of();
|
||||
return orders.merchantStatuses(customer, ids).stream().collect(java.util.stream.Collectors.toMap(
|
||||
LocalLooseboardOrderMapper.MerchantStatus::id, LocalLooseboardOrderMapper.MerchantStatus::status));
|
||||
}
|
||||
/** 路线与时间筛选共用,仅客户查询采用实际运输进度。 */
|
||||
private LambdaQueryWrapper<LocalLooseboardOrder> queryWrapper(LocalLooseboardRequests.Query query, boolean merchant) {
|
||||
LambdaQueryWrapper<LocalLooseboardOrder> wrapper = new LambdaQueryWrapper<LocalLooseboardOrder>()
|
||||
.eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId()))
|
||||
.like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo()))
|
||||
.and(StringUtils.hasText(query.routeKeyword()), value -> value
|
||||
.like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword()))
|
||||
.or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword())))
|
||||
.eq(StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus,
|
||||
.eq(!merchant && StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus,
|
||||
StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null)
|
||||
.ge(query.requestedFrom() != null, LocalLooseboardOrder::getPickupAt, query.requestedFrom() == null ? null : query.requestedFrom().atStartOfDay())
|
||||
.lt(query.requestedTo() != null, LocalLooseboardOrder::getPickupAt, query.requestedTo() == null ? null : query.requestedTo().plusDays(1).atStartOfDay())
|
||||
.orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId);
|
||||
return orders.selectPage(Page.of(query.page(), query.size()), wrapper);
|
||||
if (merchant && StringUtils.hasText(query.status())) {
|
||||
wrapper.apply(LocalLooseboardOrderMapper.MERCHANT_STATUS + "={0}", query.status().trim().toUpperCase(java.util.Locale.ROOT));
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
/** 规范查询文字,缺失值不参与筛选。 */
|
||||
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
|
||||
+90
-21
@@ -51,24 +51,40 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
||||
@Resource private com.baiyue.lastleg.looseboard.port.LooseboardDispatches dispatches;
|
||||
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPool;
|
||||
public static final String PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE";
|
||||
public static final String FUNDING_PENDING = "FUNDING_PENDING";
|
||||
public static final String FUNDING_FAILED = "FUNDING_FAILED";
|
||||
public static final String DISPATCHED = "DISPATCHED";
|
||||
public static final String IN_TRANSIT = "IN_TRANSIT";
|
||||
public static final String COMPLETED = "COMPLETED";
|
||||
public static final String CANCELLED = "CANCELLED";
|
||||
private static final Set<String> STATUSES = Set.of(PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED, "WAITING_INBOUND", "RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH");
|
||||
private static final Set<String> STATUSES = Set.of(FUNDING_PENDING, FUNDING_FAILED, PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED, "WAITING_INBOUND", "RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH");
|
||||
private static final Set<String> MERCHANT_STATUSES = Set.of(FUNDING_PENDING, FUNDING_FAILED, PENDING_ACCEPTANCE,
|
||||
"PENDING_DISPATCH", DISPATCHED, "PICKING_UP", IN_TRANSIT, COMPLETED, CANCELLED);
|
||||
private static final Map<String, Set<String>> TRANSITIONS = Map.of(
|
||||
PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED),
|
||||
FUNDING_PENDING, Set.of(CANCELLED),
|
||||
FUNDING_FAILED, Set.of(CANCELLED),
|
||||
DISPATCHED, Set.of(IN_TRANSIT, CANCELLED),
|
||||
IN_TRANSIT, Set.of(COMPLETED));
|
||||
|
||||
@Resource private LocalLooseboardRepository repository;
|
||||
@Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private com.baiyue.lastleg.wallet.budget.OrderBudgetService orderBudget;
|
||||
|
||||
/** 在同一事务保存客户需求及创建历史,等待需求池匹配后登记真实来货,创建键重复时核对请求哈希。 */
|
||||
/** 平台代客和客户自助下单共用预算冻结,冻结成功后才进入待审核。 */
|
||||
@Transactional
|
||||
public LocalLooseboardResponses.Detail create(String customerId, String operatorId,
|
||||
LocalLooseboardRequests.Create request) {
|
||||
var result = orderBudget.submit(customerId, "looseboard", request.idempotencyKey().trim(), request.budgetAmount(),
|
||||
StringUtils.hasText(request.currency()) ? norm(request.currency()) : "USD",
|
||||
() -> createPending(customerId, operatorId, request), value -> value.summary().orderNo());
|
||||
return detail(customerId, result.summary().orderNo());
|
||||
}
|
||||
|
||||
/** 业务单与冻结指令同事务保存,任何创建失败均不留下独立预占。 */
|
||||
private LocalLooseboardResponses.Detail createPending(String customerId, String operatorId,
|
||||
LocalLooseboardRequests.Create request) {
|
||||
String key = request.idempotencyKey().trim();
|
||||
String requestHash = hash(request);
|
||||
LocalLooseboardOrder replay = repository.findKey(customerId, key);
|
||||
@@ -82,7 +98,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
||||
request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(),
|
||||
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(),
|
||||
request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark());
|
||||
order.setStatus(PENDING_ACCEPTANCE);
|
||||
order.setStatus(FUNDING_PENDING);
|
||||
order.setIdempotencyKey(key);
|
||||
order.setRequestHash(requestHash);
|
||||
order.setCreatedBy(operatorId);
|
||||
@@ -97,7 +113,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
||||
if (concurrent != null) return replay(customerId, concurrent, requestHash);
|
||||
throw exception;
|
||||
}
|
||||
history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId,
|
||||
history(order, "CREATE", null, FUNDING_PENDING, operatorId,
|
||||
key, null, request.remark(), now);
|
||||
|
||||
return detail(customerId, order.getOrderNo());
|
||||
@@ -113,13 +129,28 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
||||
result.getSize(), result.getTotal(), result.getPages());
|
||||
}
|
||||
|
||||
/** 客户查询强制使用认证客户范围,沿同一需求筛选合同分页。 */
|
||||
/** 客户查询使用认证范围,筛选、分页及显示统一按真实运输进度。 */
|
||||
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
|
||||
public PageResult<LocalLooseboardResponses.Summary> page(String customerId,
|
||||
LocalLooseboardRequests.MerchantQuery query) {
|
||||
return page(new LocalLooseboardRequests.Query(query.page(), query.size(), customerId,
|
||||
query.orderNo(), query.routeKeyword(), query.status(),
|
||||
query.requestedFrom(), query.requestedTo()));
|
||||
var scoped = new LocalLooseboardRequests.Query(query.page(), query.size(), customerId,
|
||||
query.orderNo(), query.routeKeyword(), query.status(), query.requestedFrom(), query.requestedTo());
|
||||
validateQuery(scoped, MERCHANT_STATUSES);
|
||||
var result = repository.merchantPage(scoped);
|
||||
var statuses = repository.merchantStatuses(customerId, result.getRecords().stream().map(LocalLooseboardOrder::getId).toList());
|
||||
var arrangements = fleetPool.arrangements(result.getRecords().stream().map(this::poolKey).toList());
|
||||
return new PageResult<>(result.getRecords().stream()
|
||||
.map(o -> summary(o, arrangements.get(poolKey(o)), Objects.requireNonNull(statuses.get(o.getId())))).toList(),
|
||||
result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
|
||||
}
|
||||
|
||||
/** 客户详情与列表共用运输状态投影,不改写作业用原始状态。 */
|
||||
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
|
||||
public LocalLooseboardResponses.Detail merchantDetail(String customerId, String orderNo) {
|
||||
var order = repository.findNo(customerId, orderNo);
|
||||
if (order == null) throw notFound();
|
||||
var status = repository.merchantStatuses(customerId, java.util.List.of(order.getId())).get(order.getId());
|
||||
return response(order, Objects.requireNonNull(status));
|
||||
}
|
||||
|
||||
/** 按客户与业务单号读取需求及历史。 */
|
||||
@@ -134,6 +165,14 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
||||
@Transactional
|
||||
public LocalLooseboardResponses.Detail update(String customerId, String orderNo, String operatorId,
|
||||
LocalLooseboardRequests.Update request) {
|
||||
return orderBudget.update(customerId, "looseboard", orderNo, request.budgetAmount(),
|
||||
StringUtils.hasText(request.currency()) ? norm(request.currency()) : "USD",
|
||||
() -> updateLocked(customerId, orderNo, operatorId, request));
|
||||
}
|
||||
|
||||
/** 已冻结预算不允许通过普通编辑改价,业务字段仍按订单版本校验。 */
|
||||
private LocalLooseboardResponses.Detail updateLocked(String customerId, String orderNo, String operatorId,
|
||||
LocalLooseboardRequests.Update request) {
|
||||
LocalLooseboardOrder order = lock(customerId, orderNo);
|
||||
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
|
||||
if (!PENDING_ACCEPTANCE.equals(order.getStatus())) {
|
||||
@@ -158,6 +197,13 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
||||
@Transactional
|
||||
public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
|
||||
LocalLooseboardRequests.ChangeStatus request) {
|
||||
return orderBudget.withOrder(customerId, "looseboard", orderNo,
|
||||
() -> changeStatusLocked(customerId, orderNo, operatorId, request));
|
||||
}
|
||||
|
||||
/** 取消与释放意图同事务,冻结在途回执到达后也会自动释放。 */
|
||||
private LocalLooseboardResponses.Detail changeStatusLocked(String customerId, String orderNo, String operatorId,
|
||||
LocalLooseboardRequests.ChangeStatus request) {
|
||||
LocalLooseboardOrder order = lock(customerId, orderNo);
|
||||
String key = request.idempotencyKey().trim();
|
||||
String requestHash = hash(request);
|
||||
@@ -182,6 +228,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
||||
touch(order, now);
|
||||
repository.updateOrder(order);
|
||||
syncLastlegOrder(order, next, now);
|
||||
if (CANCELLED.equals(next)) orderBudget.releaseAfterReview(customerId, "looseboard", orderNo);
|
||||
LocalLooseboardOperation operation = new LocalLooseboardOperation();
|
||||
operation.setId(IdWorker.getId()); operation.setOrderId(order.getId());
|
||||
operation.setCustomerId(customerId); operation.setOperationType("STATUS");
|
||||
@@ -196,6 +243,13 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
||||
@Transactional
|
||||
public LocalLooseboardResponses.Detail review(String customerId, String orderNo, String operatorId,
|
||||
LocalLooseboardRequests.Review request) {
|
||||
return orderBudget.withOrder(customerId, "looseboard", orderNo,
|
||||
() -> reviewLocked(customerId, orderNo, operatorId, request));
|
||||
}
|
||||
|
||||
/** 冻结与审核串行核对,通过后只释放预算,实际费用继续走原费用链。 */
|
||||
private LocalLooseboardResponses.Detail reviewLocked(String customerId, String orderNo, String operatorId,
|
||||
LocalLooseboardRequests.Review request) {
|
||||
LocalLooseboardOrder order = lock(customerId, orderNo);
|
||||
String requestHash = hash(request);
|
||||
var previous = repository.findOperation(customerId, order.getId(), request.idempotencyKey());
|
||||
@@ -205,16 +259,19 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
||||
}
|
||||
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
|
||||
String decision = request.decision().trim().toUpperCase();
|
||||
if (!PENDING_ACCEPTANCE.equals(order.getStatus())) throw conflict("散板订单已审核,请刷新后重试");
|
||||
if (!PENDING_ACCEPTANCE.equals(order.getStatus())) throw conflict("散板订单须资金冻结成功且待审核,请刷新后重试");
|
||||
if ("REJECT".equals(decision)) {
|
||||
if (!StringUtils.hasText(request.remark())) throw badRequest("驳回须填写原因");
|
||||
return changeStatus(customerId, orderNo, operatorId, new LocalLooseboardRequests.ChangeStatus(order.getVersion(), "CANCELLED", request.idempotencyKey(), request.remark()));
|
||||
order.setStatus(CANCELLED);
|
||||
} else {
|
||||
if (!"APPROVE".equals(decision)) throw badRequest("审核结果无效");
|
||||
if (!orderBudget.readOrder(customerId, "looseboard", orderNo).usable()) throw conflict("散板订单资金尚未冻结成功,暂不能审核");
|
||||
if (order.getOperatingWarehouseId()==null || order.getDeliveryMode()==null) throw conflict("散板订单缺少收货安排,请先编辑补齐");
|
||||
var created = receiving.approve(order, operatorId);
|
||||
order.setTransferNo(created.transferNo()); order.setLastlegOrderId(created.lastlegOrderId()); order.setStatus("WAITING_INBOUND");
|
||||
}
|
||||
if (!"APPROVE".equals(decision)) throw badRequest("审核结果无效");
|
||||
if (order.getOperatingWarehouseId()==null || order.getDeliveryMode()==null) throw conflict("旧散板订单缺少收货安排,请先编辑补齐");
|
||||
var created = receiving.approve(order, operatorId);
|
||||
order.setTransferNo(created.transferNo()); order.setLastlegOrderId(created.lastlegOrderId()); order.setStatus("WAITING_INBOUND");
|
||||
touch(order, LocalDateTime.now()); repository.updateOrder(order);
|
||||
orderBudget.releaseAfterReview(customerId, "looseboard", orderNo);
|
||||
history(order, "REVIEW", PENDING_ACCEPTANCE, order.getStatus(), operatorId, request.idempotencyKey(), null, request.remark(), LocalDateTime.now());
|
||||
var operation=new LocalLooseboardOperation(); operation.setId(IdWorker.getId()); operation.setOrderId(order.getId()); operation.setCustomerId(customerId);
|
||||
operation.setOperationType("REVIEW"); operation.setIdempotencyKey(request.idempotencyKey()); operation.setRequestHash(requestHash); operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId); operation.setCreatedAt(LocalDateTime.now()); repository.insertOperation(operation);
|
||||
@@ -281,7 +338,12 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
||||
|
||||
/** 装配需求详情、真实承运安排、全部有效排车与不可变历史。 */
|
||||
private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order) {
|
||||
return new LocalLooseboardResponses.Detail(summary(order), order.getOriginAddress(),
|
||||
return response(order, order.getStatus());
|
||||
}
|
||||
|
||||
/** 展示状态仅用于响应,原单及历史保持真实作业状态。 */
|
||||
private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order, String status) {
|
||||
return new LocalLooseboardResponses.Detail(summary(order, fleetPool.arrangement(poolKey(order)), status), order.getOriginAddress(),
|
||||
order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(),
|
||||
repository.histories(order.getCustomerId(), order.getId()).stream()
|
||||
.map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(),
|
||||
@@ -295,16 +357,18 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
||||
return new com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey(order.getCustomerId(), "LOCAL_LOOSEBOARD", order.getId());
|
||||
}
|
||||
/** 装配原值需求摘要,预计托数与实际履约保持独立。 */
|
||||
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order) {
|
||||
return summary(order, fleetPool.arrangement(poolKey(order)));
|
||||
}
|
||||
/** 装配原值需求摘要,预计托数与实际履约保持独立。 */
|
||||
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) {
|
||||
return summary(order, arrangement, order.getStatus());
|
||||
}
|
||||
|
||||
/** 按调用方所需口径装配状态,其余原单与承运事实保持一致。 */
|
||||
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order,
|
||||
com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement, String status) {
|
||||
return new LocalLooseboardResponses.Summary(order.getId(), order.getCustomerId(), order.getOrderNo(),
|
||||
order.getPalletRequired(), order.getDeliveryMode(), order.getOperatingWarehouseId(), order.getDestinationType(), order.getDestinationPostalCode(), order.getTransferNo(), order.getDeliveryContactName(), order.getDeliveryContactPhone(), order.getEstimatedArrivalAt(), order.getTrackingNo(), order.getTransportType(), order.getOrigin(),
|
||||
order.getDestination(), order.getProductName(), order.getEstimatedPalletCount(), Objects.requireNonNullElse(order.getPackageCount(), 0),
|
||||
order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(),
|
||||
order.getOriginContactName(), order.getOriginContactPhone(), order.getDestinationContactName(), order.getDestinationContactPhone(), order.getPickupAt(), order.getDeliveryAt(), order.getStatus(),
|
||||
order.getOriginContactName(), order.getOriginContactPhone(), order.getDestinationContactName(), order.getDestinationContactPhone(), order.getPickupAt(), order.getDeliveryAt(), status,
|
||||
value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt(), arrangement);
|
||||
}
|
||||
|
||||
@@ -383,7 +447,12 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
||||
|
||||
/** 校验状态与取货日期筛选边界。 */
|
||||
private void validateQuery(LocalLooseboardRequests.Query query) {
|
||||
if (StringUtils.hasText(query.status()) && !STATUSES.contains(norm(query.status()))) {
|
||||
validateQuery(query, STATUSES);
|
||||
}
|
||||
|
||||
/** 分别限制客户可见运输状态和平台内部作业状态,并校验时间范围。 */
|
||||
private void validateQuery(LocalLooseboardRequests.Query query, Set<String> statuses) {
|
||||
if (StringUtils.hasText(query.status()) && !statuses.contains(norm(query.status()))) {
|
||||
throw badRequest("Unsupported looseboard order status");
|
||||
}
|
||||
if (query.requestedFrom() != null && query.requestedTo() != null
|
||||
|
||||
+19
@@ -12,6 +12,10 @@ import lombok.RequiredArgsConstructor;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import java.time.LocalDateTime;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
|
||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
|
||||
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
|
||||
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
|
||||
|
||||
/** 资金门禁只访问本服务采购、卡车原单及其状态历史。 */
|
||||
@Repository
|
||||
@@ -21,6 +25,21 @@ public class OrderFundingRepository {
|
||||
private final OverseasProcurementStatusHistoryMapper purchaseHistory;
|
||||
private final UsTruckOrderMapper trucks;
|
||||
private final UsTruckOrderStatusHistoryMapper truckHistory;
|
||||
private final LocalLooseboardOrderMapper looseboards;
|
||||
private final LocalLooseboardHistoryMapper looseboardHistory;
|
||||
|
||||
/** 预算锁后按认证客户锁定散板原单。 */
|
||||
public LocalLooseboardOrder looseboard(String customer, String number) { return looseboards.lockNo(customer, number); }
|
||||
/** 保存散板资金状态,不改客户填写的业务资料。 */
|
||||
public void save(LocalLooseboardOrder order) { looseboards.updateById(order); }
|
||||
/** 追加散板冻结结果和重试的不可变历史。 */
|
||||
public void history(LocalLooseboardOrder order, String from, String target, String reason, LocalDateTime now) {
|
||||
var history = new LocalLooseboardHistory();
|
||||
history.setId(IdWorker.getId()); history.setOrderId(order.getId()); history.setCustomerId(order.getCustomerId());
|
||||
history.setOperationType("FUNDING"); history.setFromStatus(from); history.setToStatus(target);
|
||||
history.setOperatorId("SYSTEM"); history.setRemark(reason); history.setCreatedAt(now);
|
||||
looseboardHistory.insert(history);
|
||||
}
|
||||
|
||||
/** 预算锁后按客户锁定采购原单,统一先预算后订单的加锁顺序。 */
|
||||
public OverseasProcurementOrder purchase(String customer, String number) { return purchases.lockNo(customer, number); }
|
||||
|
||||
+10
-1
@@ -8,7 +8,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Set;
|
||||
|
||||
/** 两种订单复用资金状态门禁,不反向依赖订单编排服务。 */
|
||||
/** 采购、卡车和散板复用资金状态门禁,不反向依赖订单编排服务。 */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OrderFundingService implements OrderFundingPort {
|
||||
@@ -36,6 +36,15 @@ public class OrderFundingService implements OrderFundingPort {
|
||||
order.setStatus(target); order.setVersion(order.getVersion() + 1); order.setUpdatedAt(now);
|
||||
repository.save(order);
|
||||
repository.history(order, from, target, reason, now);
|
||||
} else if ("looseboard".equals(budget.getKind())) {
|
||||
var order = repository.looseboard(budget.getCustomerId(), budget.getOrderNo());
|
||||
if (order == null) throw new IllegalStateException("资金关联的散板订单不存在");
|
||||
String from = order.getStatus();
|
||||
if (!changeable(from, target)) return;
|
||||
String next = "PENDING_REVIEW".equals(target) ? "PENDING_ACCEPTANCE" : target;
|
||||
order.setStatus(next); order.setVersion(order.getVersion() + 1); order.setUpdatedAt(now);
|
||||
repository.save(order);
|
||||
repository.history(order, from, next, reason, now);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -21,26 +21,26 @@ public class MerchantOrderBudgetController {
|
||||
@NotBlank @Pattern(regexp="CNY|USD") String currency) { }
|
||||
/** 按业务创建权限发起预估冻结。 */
|
||||
@PostMapping("/{kind}/{key}")
|
||||
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))")
|
||||
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create')) or (#kind == 'looseboard' and hasAuthority('merchant:cross-border:pallet-shipping:create'))")
|
||||
public ApiResponse<OrderBudgetService.View> prepare(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable @Size(max=128) String key,@Valid @RequestBody Prepare request) {
|
||||
return ApiResponse.success(service.prepare(customer(user),kind,key,request.amount(),request.currency()));
|
||||
}
|
||||
/** 只读资金回执沿同一业务权限。 */
|
||||
@GetMapping("/{kind}/{key}")
|
||||
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))")
|
||||
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create')) or (#kind == 'looseboard' and hasAuthority('merchant:cross-border:pallet-shipping:create'))")
|
||||
public ApiResponse<OrderBudgetService.View> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable String key) {
|
||||
return ApiResponse.success(service.read(customer(user),kind,key));
|
||||
}
|
||||
/** 列表及详情可核对当前客户订单资金状态和失败原因。 */
|
||||
@GetMapping("/{kind}/orders/{orderNo}")
|
||||
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:list')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:list'))")
|
||||
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:list')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:list')) or (#kind == 'looseboard' and hasAuthority('merchant:cross-border:pallet-shipping:list'))")
|
||||
public ApiResponse<OrderBudgetService.View> readOrder(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String kind, @PathVariable String orderNo) {
|
||||
return ApiResponse.success(service.readOrder(customer(user), kind, orderNo));
|
||||
}
|
||||
/** 失败订单按原预算重试;客户身份只取当前会话。 */
|
||||
@PostMapping("/{kind}/orders/{orderNo}/retry")
|
||||
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))")
|
||||
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create')) or (#kind == 'looseboard' and hasAuthority('merchant:cross-border:pallet-shipping:create'))")
|
||||
public ApiResponse<OrderBudgetService.View> retry(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String kind, @PathVariable String orderNo) {
|
||||
return ApiResponse.success(service.retryOrder(customer(user), kind, orderNo));
|
||||
|
||||
@@ -24,6 +24,7 @@ public interface OrderBudgetMapper extends BaseMapper<OrderBudget> {
|
||||
(b.order_no IS NULL AND b.created_at < #{expiredBefore})
|
||||
OR (b.kind='procurement' AND EXISTS (SELECT 1 FROM ll_overseas_procurement_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED','EXCEPTION_CLOSED')))
|
||||
OR (b.kind='trucking' AND EXISTS (SELECT 1 FROM ll_us_truck_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED')))
|
||||
OR (b.kind='looseboard' AND EXISTS (SELECT 1 FROM ll_local_looseboard_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('WAITING_INBOUND','RECEIVING','WAITING_CONSOLIDATION','PENDING_DISPATCH','DISPATCHED','IN_TRANSIT','CANCELLED','COMPLETED')))
|
||||
)) ORDER BY b.id LIMIT 100 FOR UPDATE SKIP LOCKED
|
||||
""")
|
||||
List<OrderBudget> releasable(@Param("expiredBefore") LocalDateTime expiredBefore,
|
||||
|
||||
+10
-2
@@ -115,7 +115,15 @@ public class OrderBudgetService implements WalletResultHandler {
|
||||
return change.get();
|
||||
}
|
||||
|
||||
/** 审核通过后立即释放采购或卡车预估冻结,后续实际费用按正常余额/授信扣除。 */
|
||||
/** 审核及取消先锁预算再锁业务单,与异步资金回执保持相同锁序。 */
|
||||
@Transactional
|
||||
public <T> T withOrder(String customer,String kind,String no,Supplier<T> change) {
|
||||
requireKind(kind);
|
||||
repository.lockOrder(customer,kind,no);
|
||||
return change.get();
|
||||
}
|
||||
|
||||
/** 审核通过后立即释放订单预估冻结,后续实际费用按正常余额/授信扣除。 */
|
||||
@Transactional
|
||||
public void releaseAfterReview(String customer, String kind, String orderNo) {
|
||||
requireKind(kind);
|
||||
@@ -194,7 +202,7 @@ public class OrderBudgetService implements WalletResultHandler {
|
||||
};return new View(row.getStatus(),row.getAmountCny(),message,row.getOrderNo(),"FROZEN".equals(row.getStatus()) && !Boolean.TRUE.equals(row.getReleaseRequested()) && (row.getOrderNo()!=null || !row.getCreatedAt().isBefore(now().minusMinutes(15))));
|
||||
}
|
||||
/** 仅开放本轮客户下单业务。 */
|
||||
private void requireKind(String kind) { if(!Set.of("procurement","trucking").contains(kind)) throw bad("不支持的预算业务"); }
|
||||
private void requireKind(String kind) { if(!Set.of("procurement","trucking","looseboard").contains(kind)) throw bad("不支持的预算业务"); }
|
||||
/** 时间按UTC保存以匹配资金事件。 */
|
||||
private LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); }
|
||||
/** 返回可恢复的业务校验失败。 */
|
||||
|
||||
@@ -185,7 +185,7 @@ paths:
|
||||
parameters: [{$ref: '#/components/parameters/Id'}]
|
||||
post:
|
||||
summary: 上传并绑定驾驶证,重置为待审
|
||||
description: '权限:platform:overseas-master-data:driver:upload;复合归属固定平台0、FLEET_DRIVER_LICENSE、当前司机;非空PDF/JPG/PNG不超过10MiB,校验MIME和签名,旧版本附件保留在历史中。'
|
||||
description: '权限:platform:overseas-master-data:driver:upload;复合归属固定平台0、FLEET_DRIVER_LICENSE、当前司机;上传仅接受非空JPG/PNG且不超过10MiB,校验MIME和签名,旧版本附件按历史引用读取。'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"info": {
|
||||
"title": "真实出车与自有外接运营统计",
|
||||
"version": "1.0.0",
|
||||
"description": "仅实际departed_at非空车次;使用DEPART不可变快照,一车次计一次,跨客户或多目的地不重复。物理托盘去重;自有与外接箱数以BOUND外接实收归属核算,支持同排车/同托混货。全筛选汇总与分页同快照;sourceType筛选含该类货的整车,并非裁切另一类货物。from含、to不含,UTC,最多366天/5000车次。发车车牌/司机使用历史,货物结案状态使用当前事实。约定金额按币种分别累计,不是财务应收/实收;列表财务按需读取,未读取明确NOT_QUERIED;单车次财务仅按DEPART客户/排车核对唯一派送来源,Finance返回已审核有效应收与明确归属整车次账单的确认收款分配。混合来源、无收款分配或多车次归属不估算,显示待分配。应收额仍按Finance实际币种展示,不把外接约定金额当应收;成本只通过FleetTripCostService.summaries(Collection<String>)按最多100车次一批读取,保持现有trip:list成本读权限。无权限为FORBIDDEN,既有车次无登记为NOT_RECORDED;可读汇总仅包含已登记直接成本,返回未登记及未能分摊车次数,未知分摊值为null。查询不跨服务逐行HTTP,不写执行状态。 图表与分页共用全筛选快照:按UTC日展示实运托盘及外接约定金额,目的地按稳定键统计涉及车次。按托均价仅包含PALLET计价,用该币种约定总金额除确认计价标准托位,ORDER报价排除。里程mile、油耗L是授权补充事实,与车辆档案累计里程和直接成本独立。"
|
||||
"description": "仅实际departed_at非空的派送、取货及本土卡车车次;分别读取DEPART、PICKUP_DEPART/PICKUP_PICKED或LOCAL_TRUCK_DEPART不可变发车事实,取货历史须带实际发车时间,一车次计一次,跨客户或多目的地不重复。物理托盘去重;自有与外接箱数以BOUND外接实收归属核算,支持同排车/同托混货。全筛选汇总与分页同快照;sourceType筛选含该类货的整车,并非裁切另一类货物。from含、to不含,UTC,最多366天/5000车次。发车车牌/司机使用历史,货物结案状态使用当前事实。约定金额按币种分别累计,不是财务应收/实收;列表财务按需读取,未读取明确NOT_QUERIED;单车次财务仅按DEPART客户/排车核对唯一派送来源,Finance返回已审核有效应收与明确归属整车次账单的确认收款分配。混合来源、无收款分配或多车次归属不估算,显示待分配。应收额仍按Finance实际币种展示,不把外接约定金额当应收;成本只通过FleetTripCostService.summaries(Collection<String>)按最多100车次一批读取,保持现有trip:list成本读权限。无权限为FORBIDDEN,既有车次无登记为NOT_RECORDED;可读汇总仅包含已登记直接成本,返回未登记及未能分摊车次数,未知分摊值为null。查询不跨服务逐行HTTP,不写执行状态。 图表与分页共用全筛选快照:按UTC日展示实运托盘及外接约定金额,目的地按稳定键统计涉及车次。按托均价仅包含PALLET计价,用该币种约定总金额除确认计价标准托位,ORDER报价排除。里程mile、油耗L是授权补充事实,与车辆档案累计里程和直接成本独立。"
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
@@ -13,7 +13,7 @@
|
||||
"paths": {
|
||||
"/api/platform/v1/last-leg/fleet/operations": {
|
||||
"get": {
|
||||
"description": "platform:fleet:operations:list;一次查询返回分页与全范围汇总",
|
||||
"description": "platform:fleet:operations:list;一次查询返回分页与全范围汇总。纯散板照片直送按LOOSEBOARD_DELIVERING不可变历史的实际发运箱数统计,要求已有实际发车时间且未发生仓内拣货或装车;实托和标准托位保持null,不以申报托数补齐。普通仓内出车仍要求DEPART实载快照。",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "page",
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 平台收货异常核对
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/api/platform/v1/last-leg/inbound-plans/{planNo}/receiving-reconciliation:
|
||||
parameters:
|
||||
- { name: planNo, in: path, required: true, schema: { type: string } }
|
||||
get:
|
||||
operationId: getInboundReceivingReconciliation
|
||||
summary: 查询收货异常核对
|
||||
description: 仅平台入库列表权限。按所选客户计划的来源柜次、柜号和实际接柜仓查询同柜计划。独立返回现场实盘与已分配数量,完成票据只读。普通FBA单票单SKU组可唯一确定归属时预填完整实盘,其余逐票逐SKU由平台显式核对。
|
||||
parameters:
|
||||
- { name: customerId, in: query, required: true, schema: { type: string } }
|
||||
responses:
|
||||
'200':
|
||||
description: 当前事实快照和确认资格
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: string }
|
||||
data: { $ref: '#/components/schemas/Snapshot' }
|
||||
post:
|
||||
operationId: confirmInboundReceivingReconciliation
|
||||
summary: 确认收货异常
|
||||
description: 仅平台入库编辑权限。必须提交当前snapshotHash、明确原因以及快照内全部逐托和逐票SKU明细。普通FBA可确认短少、超收、零实收或更正误录,每托归属合计须等于核实清点数,单托可编辑数量0至66。普通地址逐箱来源和已入库票据保持只读。原申报不变,按原现场时间生成实际库存及计租批次,逐SKU短少进入PARTIAL_INBOUND,其余进入COMPLETED。库存、托盘、原预排交接和审计同事务提交,保留原清点及归属事实,不重复发布卸柜事件。已完成一件代发来源独立分组,待核对一件代发仍须核实冻结整托身份。
|
||||
parameters:
|
||||
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, maxLength: 128 } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Confirm' }
|
||||
responses:
|
||||
'200':
|
||||
description: 确认结果,同键同操作人同载荷重放返回原结果
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: string }
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
planNo: { type: string }
|
||||
status: { type: string }
|
||||
confirmedPackageCount: { type: integer }
|
||||
reconciledPlanNos: { type: array, items: { type: string } }
|
||||
'409': { description: 旧快照、归属不完整、逐托不平、已完成来源变化或证据缺失 }
|
||||
components:
|
||||
schemas:
|
||||
Snapshot:
|
||||
type: object
|
||||
required: [planNo, containerNo, snapshotHash, expectedPackageCount, countedPackageCount, attributedPackageCount, canConfirm, plans, pallets, receipts]
|
||||
properties:
|
||||
planNo: { type: string }
|
||||
containerNo: { type: string }
|
||||
snapshotHash: { type: string }
|
||||
expectedPackageCount: { type: integer, description: 本柜原申报箱数 }
|
||||
countedPackageCount: { type: integer, description: 独立现场实盘箱数 }
|
||||
attributedPackageCount: { type: integer, description: 当前已归属货件的箱数 }
|
||||
canConfirm: { type: boolean }
|
||||
blockedReason: { type: string, nullable: true }
|
||||
plans:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
customerId: { type: string }
|
||||
planNo: { type: string }
|
||||
customerName: { type: string, nullable: true }
|
||||
status: { type: string }
|
||||
expectedPackageCount: { type: integer }
|
||||
attributedPackageCount: { type: integer }
|
||||
pallets:
|
||||
type: array
|
||||
items:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/Count' }
|
||||
- type: object
|
||||
properties:
|
||||
palletNo: { type: string }
|
||||
groupKey: { type: string }
|
||||
attributedPackageCount: { type: integer }
|
||||
editable: { type: boolean }
|
||||
receipts:
|
||||
type: array
|
||||
items:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/Quantity' }
|
||||
- type: object
|
||||
properties:
|
||||
inboundCode: { type: string, nullable: true }
|
||||
palletNo: { type: string }
|
||||
productName: { type: string, nullable: true }
|
||||
expectedPackageCount: { type: integer, description: 该票SKU原申报合计,同SKU跨托显示重复值 }
|
||||
editable: { type: boolean }
|
||||
Confirm:
|
||||
type: object
|
||||
required: [customerId, snapshotHash, reason, pallets, receipts]
|
||||
properties:
|
||||
customerId: { type: string }
|
||||
snapshotHash: { type: string }
|
||||
reason: { type: string, minLength: 1, maxLength: 200 }
|
||||
pallets: { type: array, items: { $ref: '#/components/schemas/Count' } }
|
||||
receipts: { type: array, items: { $ref: '#/components/schemas/Quantity' } }
|
||||
Count:
|
||||
type: object
|
||||
required: [palletId, countedPackageCount]
|
||||
properties:
|
||||
palletId: { type: string }
|
||||
countedPackageCount: { type: integer, minimum: 0 }
|
||||
Quantity:
|
||||
type: object
|
||||
required: [customerId, planNo, lineId, palletId, skuCode, receivedPackageCount]
|
||||
properties:
|
||||
customerId: { type: string }
|
||||
planNo: { type: string }
|
||||
lineId: { type: string }
|
||||
palletId: { type: string }
|
||||
skuCode: { type: string }
|
||||
receivedPackageCount: { type: integer, minimum: 0 }
|
||||
@@ -2,17 +2,17 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: 海外客户下单预估资金校验
|
||||
version: 1.0.0
|
||||
description: 客户订单先原子落库并进入资金处理中,资金冻结通过Outbox异步执行。冻结成功后进入待审核,失败进入资金失败并可在原订单上重试;平台仅可审核待审核订单。余额优先、授信补足及不足判断由Finance原子执行;本服务不自行修改钱包。预算预占、订单和冻结指令在同一事务内写入,订单终态释放。
|
||||
description: 客户订单先原子落库并进入资金处理中,资金冻结通过Outbox异步执行。冻结成功后进入待审核,失败进入资金失败并可在原订单上重试;平台仅可审核待审核订单。余额优先、授信补足及不足判断由Finance原子执行;本服务不自行修改钱包。预算预占、订单和冻结指令在同一事务内写入,审核通过立即申请释放原预算,取消、驳回或终态同样释放;释放失败自动重试。散板冻结成功进入PENDING_ACCEPTANCE,失败进入FUNDING_FAILED。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/merchant/v1/last-leg/order-budgets/{kind}/{key}:
|
||||
parameters:
|
||||
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}}
|
||||
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking, looseboard]}}
|
||||
- {name: key, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
|
||||
post:
|
||||
summary: 兼容接口:发起或读取预算冻结请求
|
||||
description: 客户身份仅来自认证。procurement需要merchant:cross-border:procurement:create,trucking需要merchant:cross-border:domestic-trucking:create;权限不能交叉使用。采购金额是预算单价乘采购数量,免费样品零金额不发资金指令;卡车金额必须大于0。重试禁止更改金额或币种。
|
||||
description: 客户身份仅来自认证。procurement需要merchant:cross-border:procurement:create,trucking需要merchant:cross-border:domestic-trucking:create,looseboard需要merchant:cross-border:pallet-shipping:create;权限不能交叉使用。采购金额是预算单价乘采购数量,免费样品零金额不发资金指令;卡车金额必须大于0。重试禁止更改金额或币种。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -34,16 +34,16 @@ paths:
|
||||
'403': {description: 身份或业务创建权限不足}
|
||||
/api/merchant/v1/last-leg/order-budgets/{kind}/orders/{orderNo}:
|
||||
parameters:
|
||||
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}}
|
||||
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking, looseboard]}}
|
||||
- {name: orderNo, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
|
||||
get:
|
||||
summary: 读取订单资金状态
|
||||
description: 仅当前客户可读。FUNDING_PENDING期间客户端可短暂轮询;FUNDING_FAILED展示失败原因并允许重试。只有PENDING_REVIEW允许平台审核。
|
||||
description: 仅当前客户可读。FUNDING_PENDING期间客户端可短暂轮询;FUNDING_FAILED展示失败原因并允许重试。采购和卡车只有PENDING_REVIEW、散板只有PENDING_ACCEPTANCE且资金已冻结才允许平台审核。读取订单资金状态分别要求对应业务list权限,重试要求对应create权限。
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Result'}
|
||||
/api/merchant/v1/last-leg/order-budgets/{kind}/orders/{orderNo}/retry:
|
||||
parameters:
|
||||
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}}
|
||||
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking, looseboard]}}
|
||||
- {name: orderNo, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
|
||||
post:
|
||||
summary: 重试订单资金冻结
|
||||
|
||||
@@ -28,7 +28,7 @@ paths:
|
||||
/api/platform/v1/last-leg/local-looseboard-orders/{orderNo}/review:
|
||||
post:
|
||||
summary: 审核散板并创建唯一入仓履约单
|
||||
description: 权限platform:overseas-business:looseboard:status;通过后送货到仓进入PDA收货,仓库上门取货进入取货排车;审核后即可从散板需求添加申报货物;上门取货沿取货排车上传取货与到仓凭证,实收到仓后原单进入PENDING_DISPATCH并交接原排车货量。客户及幂等键严格校验。
|
||||
description: 权限platform:overseas-business:looseboard:status;仅冻结成功可通过审核,通过和驳回均释放原预算预占;通过后送货到仓进入PDA收货,仓库上门取货进入取货排车;审核后即可从散板需求添加申报货物;上门取货沿取货排车上传取货与到仓凭证,实收到仓后原单进入PENDING_DISPATCH并交接原排车货量。客户及幂等键严格校验。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: orderNo, in: path, required: true, schema: { type: string } }
|
||||
@@ -2754,20 +2754,20 @@ paths:
|
||||
/api/merchant/v1/last-leg/local-looseboard-orders:
|
||||
get:
|
||||
summary: 客户分页查询自己的本土散板订单
|
||||
description: 权限 merchant:cross-border:pallet-shipping:list;客户范围只取认证 Token,不接收 customerId。
|
||||
description: 权限 merchant:cross-border:pallet-shipping:list;客户范围只取认证 Token,不接收 customerId;状态筛选、分页计数和显示共用真实运输进度。PENDING_ACCEPTANCE 为待审核,审核通过尚未排车为 PENDING_DISPATCH(待排车);已排车待取货为 DISPATCHED;取货到仓车次出发或已取货未到仓为 PICKING_UP(取货中),到仓后等待后续排车;直接取货送货取货完成后为 IN_TRANSIT(派送中)。内部收货与合托状态不作为客户筛选项。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
|
||||
- { name: orderNo, in: query, schema: { type: string } }
|
||||
- { name: routeKeyword, in: query, schema: { type: string } }
|
||||
- { name: status, in: query, schema: { $ref: '#/components/schemas/LooseboardStatus' } }
|
||||
- { name: status, in: query, schema: { $ref: '#/components/schemas/MerchantLooseboardStatus' } }
|
||||
- { name: requestedFrom, in: query, schema: { type: string, format: date } }
|
||||
- { name: requestedTo, in: query, schema: { type: string, format: date } }
|
||||
responses: { '200': { description: 当前客户本土散板订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardPageResponse' } } } } }
|
||||
post:
|
||||
summary: 客户创建本土散板订单
|
||||
description: 权限 merchant:cross-border:pallet-shipping:create;新单固定进入 PENDING_ACCEPTANCE,客户不能调用平台状态推进端点。
|
||||
description: 权限 merchant:cross-border:pallet-shipping:create;散板金额必填,预算与订单和冻结指令同事务保存;新单进入 FUNDING_PENDING,Finance按余额优先、授信补足冻结,成功进入 PENDING_ACCEPTANCE,失败进入 FUNDING_FAILED,支持原单重试。审核通过、取消或驳回申请释放预占,不自动扣款;客户不能调用平台状态推进端点。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardCreate' } } } }
|
||||
responses: { '200': { description: 本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 幂等冲突 } }
|
||||
@@ -2775,12 +2775,12 @@ paths:
|
||||
parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }]
|
||||
get:
|
||||
summary: 客户查询自己的本土散板订单详情
|
||||
description: 权限 merchant:cross-border:pallet-shipping:list;不泄露其他客户订单。
|
||||
description: 权限 merchant:cross-border:pallet-shipping:list;不泄露其他客户订单;summary.status 使用 MerchantLooseboardStatus,与列表运输状态及筛选一致。
|
||||
security: [{ bearerAuth: [] }]
|
||||
responses: { '200': { description: 当前客户本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } }
|
||||
put:
|
||||
summary: 客户编辑待接单的本土散板订单
|
||||
description: 权限 merchant:cross-border:pallet-shipping:edit;仅 PENDING_ACCEPTANCE 可编辑,version 必填;不能修改状态。
|
||||
summary: 客户编辑待审核的本土散板订单
|
||||
description: 权限 merchant:cross-border:pallet-shipping:edit;仅 PENDING_ACCEPTANCE 可编辑,version 必填;已冻结的预算金额与币种不能通过编辑更改;不能修改状态。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardUpdate' } } } }
|
||||
responses: { '200': { description: 更新后的订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 版本或状态冲突 } }
|
||||
@@ -4024,6 +4024,7 @@ components:
|
||||
required: [sourceContainerBookingId, containerNo, overseasWarehouseCode, overseasWarehouseName, status, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, operable, allPlansReady, currentAccount]
|
||||
properties:
|
||||
sourceContainerBookingId: { type: string, description: 头程与尾程共享的稳定柜身份 }
|
||||
cabinetNo: { type: string, nullable: true, description: 头程装柜事件快照中的正式柜次号,缺失时为空 }
|
||||
containerNo: { type: string }
|
||||
overseasWarehouseCode: { type: string }
|
||||
overseasWarehouseName: { type: string }
|
||||
@@ -4047,6 +4048,7 @@ components:
|
||||
type: object
|
||||
required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, domesticLoadingPhotos, totalPackages, receivedPackages, pendingPackages, hasDifference, inbounds]
|
||||
properties:
|
||||
cabinetNo: { type: string, nullable: true, description: 当前柜次对应的头程装柜事件快照中的正式柜次号,缺失时为空 }
|
||||
containerNo: { type: string }
|
||||
overseasWarehouseId: { type: string, pattern: '^\d+$', description: 平台预分配并与PDA Token匹配的海外接柜仓ID }
|
||||
overseasWarehouseCode: { type: string, description: 海外接柜仓业务编码快照 }
|
||||
@@ -4897,7 +4899,11 @@ components:
|
||||
destinationContactPhone: { type: string, nullable: true }
|
||||
pickupAt: { type: string, format: date-time, nullable: true, description: 当地日期时间不带时区;按分钟输入 }
|
||||
deliveryAt: { type: string, format: date-time, nullable: true, description: 当地日期时间,不能早于取货时间 }
|
||||
status: { $ref: '#/components/schemas/LooseboardStatus' }
|
||||
status:
|
||||
description: 客户读取为运输状态 MerchantLooseboardStatus,平台读取保留内部作业状态 LooseboardStatus。
|
||||
anyOf:
|
||||
- { $ref: '#/components/schemas/LooseboardStatus' }
|
||||
- { $ref: '#/components/schemas/MerchantLooseboardStatus' }
|
||||
version: { type: integer, format: int64 }
|
||||
createdAt: { type: string, format: date-time }
|
||||
updatedAt: { type: string, format: date-time }
|
||||
@@ -5292,7 +5298,11 @@ components:
|
||||
pickupAt: { type: string, format: date-time, description: '取货日期时分,使用无偏移量的本地时间,不转换时区' }
|
||||
deliveryAt: { type: string, format: date-time, description: '送货日期时分,不能早于取货时间,不转换时区' }
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
LooseboardStatus: { type: string, enum: [PENDING_ACCEPTANCE, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] }
|
||||
LooseboardStatus: { type: string, enum: [FUNDING_PENDING, FUNDING_FAILED, PENDING_ACCEPTANCE, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] }
|
||||
MerchantLooseboardStatus:
|
||||
type: string
|
||||
description: 订单受理中、订单处理失败、待审核、待排车、待取货、取货中、派送中、已完成、已取消;只读运输进度,不作为平台状态推进命令。
|
||||
enum: [FUNDING_PENDING, FUNDING_FAILED, PENDING_ACCEPTANCE, PENDING_DISPATCH, DISPATCHED, PICKING_UP, IN_TRANSIT, COMPLETED, CANCELLED]
|
||||
LooseboardStatusChange:
|
||||
type: object
|
||||
required: [version, status, idempotencyKey]
|
||||
@@ -5303,7 +5313,7 @@ components:
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
LooseboardCreate:
|
||||
type: object
|
||||
required: [operatingWarehouseId, deliveryMode, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey]
|
||||
required: [operatingWarehouseId, deliveryMode, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, budgetAmount, idempotencyKey]
|
||||
properties:
|
||||
deliveryMode: { type: string, enum: [DELIVERY_TO_WAREHOUSE, WAREHOUSE_PICKUP], description: 送货到仓或仓库上门取货 }
|
||||
operatingWarehouseId: { type: string, description: 实际收货海外仓ID }
|
||||
@@ -5322,7 +5332,7 @@ components:
|
||||
packageCount: { type: integer, minimum: 0 }
|
||||
totalWeightKg: { type: number, minimum: 0 }
|
||||
totalVolumeCbm: { type: number, minimum: 0 }
|
||||
budgetAmount: { type: number, nullable: true, minimum: 0 }
|
||||
budgetAmount: { type: number, minimum: 0, multipleOf: 0.01 }
|
||||
currency: { type: string, minLength: 3, maxLength: 3, default: USD }
|
||||
originContactName: { type: string, minLength: 1, maxLength: 128 }
|
||||
originContactPhone: { type: string, minLength: 1, maxLength: 128 }
|
||||
|
||||
+3
-2
@@ -111,10 +111,11 @@ class DriverServiceTest {
|
||||
assertThat(service.update(71, "e", new DriverModels.Update(3L, changedExpiry)).reviewStatus()).isEqualTo("PENDING");
|
||||
}
|
||||
|
||||
/** 绑定新图片后,司机证照恢复待审并保留旧附件的历史关联。 */
|
||||
@Test void replacingLicenseBindsNewFileAndResetsApproval() {
|
||||
when(drivers.lockById(71)).thenReturn(row("APPROVED"));
|
||||
var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-test".getBytes());
|
||||
when(files.upload(71, "e", file)).thenReturn(new DriverModels.File("82", "license.pdf", "application/pdf", 9));
|
||||
var file = new MockMultipartFile("file", "license.png", "image/png", "image".getBytes());
|
||||
when(files.upload(71, "e", file)).thenReturn(new DriverModels.File("82", "license.png", "image/png", 5));
|
||||
var response = service.uploadLicense(71, 2, "e", file);
|
||||
assertThat(response.licenseFileId()).isEqualTo("82");
|
||||
assertThat(response.reviewStatus()).isEqualTo("PENDING");
|
||||
|
||||
+17
@@ -1,10 +1,27 @@
|
||||
package com.baiyue.lastleg.fleet.driver;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class HttpDriverFileGatewayTest {
|
||||
/** 上传入口在访问 File 服务前拒绝 PDF、其他图片格式及空图片。 */
|
||||
@Test void rejectsNonImageDriverUploadsBeforeCallingFileService() {
|
||||
var client = mock(org.springframework.web.client.RestClient.class);
|
||||
var factory = mock(com.baiyue.lastleg.config.LastlegRestClientFactory.class);
|
||||
when(factory.build(any(), anyString(), any())).thenReturn(client);
|
||||
var gateway = new HttpDriverFileGateway(org.springframework.web.client.RestClient.builder(), factory,
|
||||
new com.baiyue.lastleg.config.LastlegRestClientProperties(), "http://file.test", "test-token");
|
||||
for (var file : new MockMultipartFile[]{
|
||||
new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7".getBytes()),
|
||||
new MockMultipartFile("file", "license.gif", "image/gif", "GIF89a".getBytes()),
|
||||
new MockMultipartFile("file", "license.png", "image/png", new byte[0])
|
||||
}) assertThatThrownBy(() -> gateway.upload(71, "7", file)).hasMessageContaining("JPG 或 PNG");
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
@Test void acceptsOnlyMatchingPlatformDriverLicenseMetadata() {
|
||||
assertThat(HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 12L), 71, 81L).fileId())
|
||||
.isEqualTo("81");
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package com.baiyue.lastleg.fleet.reporting;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetTripCostService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class TripOperationsRepositoryTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private TripOperationsRepository repository;
|
||||
|
||||
/** 使用真实 Mapper 覆盖灰度历史与测试版运输查询的合并。 */
|
||||
@BeforeEach void setUp() throws Exception {
|
||||
var source = new DriverManagerDataSource(
|
||||
"jdbc:h2:mem:trip-report-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;INIT=SET TIME ZONE '+08:00'", "sa", "");
|
||||
jdbc = new JdbcTemplate(source);
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(64),trip_purpose VARCHAR(16),status VARCHAR(32),warehouse_id BIGINT,departed_at TIMESTAMP,completed_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip_history(trip_id BIGINT,record_version BIGINT,action VARCHAR(32),snapshot_json CLOB)");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip_operations_record(trip_no VARCHAR(64),record_version BIGINT,details_json CLOB)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,trip_id BIGINT,task_type VARCHAR(32),package_count INT)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_history(id BIGINT PRIMARY KEY,dispatch_id BIGINT,record_version BIGINT,action VARCHAR(32),snapshot_json CLOB)");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_pickup_task(trip_no VARCHAR(64),status VARCHAR(32),actual_packages INT)");
|
||||
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR '" + getClass().getName() + ".jsonExtract'");
|
||||
jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR '" + getClass().getName() + ".jsonUnquote'");
|
||||
var configuration = new MybatisConfiguration();
|
||||
configuration.setMapUnderscoreToCamelCase(true);
|
||||
configuration.addMapper(TripOperationsQueryMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(source);
|
||||
factory.setConfiguration(configuration);
|
||||
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(TripOperationsQueryMapper.class);
|
||||
repository = new TripOperationsRepository(mapper);
|
||||
}
|
||||
|
||||
/** 释放本例数据库,避免定向回归累积隔离状态。 */
|
||||
@AfterEach void closeDatabase() {
|
||||
if (jdbc != null) jdbc.execute("SHUTDOWN");
|
||||
}
|
||||
|
||||
/** H2 替身读取真实嵌套 JSON,缺失路径保持 SQL NULL。 */
|
||||
public static String jsonExtract(String document, String path) throws Exception {
|
||||
if (document == null) return null;
|
||||
var value = new ObjectMapper().readTree(document).at(path.substring(1).replace('.', '/'));
|
||||
return value.isMissingNode() ? null : value.toString();
|
||||
}
|
||||
|
||||
/** 保留字符串及 JSON null 的解码语义。 */
|
||||
public static String jsonUnquote(String document) throws Exception {
|
||||
return document == null ? null : new ObjectMapper().readTree(document).asText();
|
||||
}
|
||||
|
||||
/** 三类车次各取真实发车历史,取货初始空记录不能覆盖实提事实。 */
|
||||
@Test void pickupAndLocalTruckFactsUseTheirOwnHistoryWithoutBreakingDeliveryReport() throws Exception {
|
||||
trip(1, "TR-A", "DELIVERY", "IN_TRANSIT", 1, "2026-10-01 00:00:00");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,3,'DEPART',?)", TripOperationsServiceTest.DEPARTURE);
|
||||
trip(2, "PICKUP-A", "PICKUP", "COMPLETED", 1, "2026-10-02 00:00:00");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(2,1,'PICKUP_PICKED','{}'),(2,4,'PICKUP_ARRIVED','{}')");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(2,3,'PICKUP_PICKED',?)", transportSnapshot("PICKUP-A", "PICKUP"));
|
||||
jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES('PICKUP-A','PICKED',5),('PICKUP-A','ARRIVED',2),('PICKUP-A','PENDING',99)");
|
||||
trip(3, "TRUCK-A", "LOCAL_TRUCK", "IN_TRANSIT", 1, "2026-10-02 00:00:00");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(3,2,'LOCAL_TRUCK_DEPART',?)", transportSnapshot("TRUCK-A", "LOCAL_TRUCK"));
|
||||
jdbc.update("INSERT INTO ll_dispatch_order VALUES(3,3,'LOCAL_TRUCK',9)");
|
||||
|
||||
var result = service().report(query(null, null), false);
|
||||
|
||||
assertThat(result.page().records()).extracting(TripOperationsModels.Row::tripNo).containsExactly("TRUCK-A", "PICKUP-A", "TR-A");
|
||||
assertThat(result.summary().trips()).isEqualTo(3);
|
||||
assertThat(result.summary().physicalPallets()).isEqualTo(1);
|
||||
assertThat(result.summary().ownPackages()).isEqualTo(27);
|
||||
assertThat(result.summary().externalPackages()).isEqualTo(4);
|
||||
assertThat(result.page().records().get(0).physicalPallets()).isNull();
|
||||
assertThat(result.page().records().get(1).physicalPallets()).isNull();
|
||||
}
|
||||
|
||||
/** 非 UTC 连接仍按 UTC 含起点、不含终点筛选,完成时间也不能发生偏移。 */
|
||||
@Test void deliveryFiltersKeepInclusiveStartExclusiveEndAndWarehouseStatus() {
|
||||
trip(1, "START", "DELIVERY", "COMPLETED", 1, "2026-10-01 00:00:00");
|
||||
trip(2, "END", "DELIVERY", "COMPLETED", 1, "2026-10-03 00:00:00");
|
||||
trip(3, "BEFORE", "DELIVERY", "COMPLETED", 1, "2026-09-30 23:59:59");
|
||||
trip(4, "OTHER-WAREHOUSE", "DELIVERY", "COMPLETED", 2, "2026-10-02 00:00:00");
|
||||
trip(5, "IN-TRANSIT", "DELIVERY", "IN_TRANSIT", 1, "2026-10-02 00:00:00");
|
||||
jdbc.update("UPDATE ll_fleet_trip SET completed_at='2026-10-02 14:15:00' WHERE id=1");
|
||||
assertThat(repository.departures(query(1L, "COMPLETED")))
|
||||
.extracting(TripOperationsRepository.Departure::tripNo).containsExactly("START");
|
||||
assertThat(repository.departures(query(1L, "COMPLETED")).get(0).completedAt())
|
||||
.isEqualTo(Instant.parse("2026-10-02T14:15:00Z"));
|
||||
}
|
||||
|
||||
/** 三类车次缺少匹配历史都应显式报错,不能静默丢弃。 */
|
||||
@Test void missingMatchingHistoryStillFailsForEveryTripPurpose() {
|
||||
for (String purpose : java.util.List.of("DELIVERY", "PICKUP", "LOCAL_TRUCK")) {
|
||||
jdbc.update("DELETE FROM ll_fleet_trip_history");
|
||||
jdbc.update("DELETE FROM ll_fleet_trip");
|
||||
trip(1, "MISSING", purpose, "IN_TRANSIT", 1, "2026-10-01 00:00:00");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,1,'PICKUP_PICKED','{}')");
|
||||
assertThatThrownBy(() -> service().report(query(null, null), false))
|
||||
.isInstanceOf(IllegalStateException.class).hasMessageContaining("不可变历史").hasMessageContaining("MISSING");
|
||||
}
|
||||
}
|
||||
|
||||
/** 保留显式取货发车及旧版 DEPART 动作,未登记实提箱数不补预报。 */
|
||||
@Test void pickupDepartureAndLegacyDepartActionsRemainReadable() throws Exception {
|
||||
for (String action : java.util.List.of("PICKUP_DEPART", "DEPART")) {
|
||||
jdbc.update("DELETE FROM ll_fleet_trip_history");
|
||||
jdbc.update("DELETE FROM ll_fleet_trip");
|
||||
trip(1, "PICKUP-A", "PICKUP", "IN_TRANSIT", 1, "2026-10-01 00:00:00");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,1,?,?)", action, transportSnapshot("PICKUP-A", "PICKUP"));
|
||||
var result = service().report(query(null, null), false);
|
||||
assertThat(result.page().records()).extracting(TripOperationsModels.Row::tripNo).containsExactly("PICKUP-A");
|
||||
assertThat(result.summary().ownPackages()).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
/** 运输发车快照保留行程和实际时间,不伪造仓库装车托数。 */
|
||||
private String transportSnapshot(String number, String purpose) throws Exception {
|
||||
var json = new ObjectMapper();
|
||||
var snapshot = json.createObjectNode();
|
||||
var trip = snapshot.putObject("trip");
|
||||
trip.put("tripNo", number);
|
||||
trip.put("tripPurpose", purpose);
|
||||
trip.put("departedAt", "2026-10-01T00:00:00Z");
|
||||
var stops = trip.putArray("stops");
|
||||
stops.addObject().put("name", "起点");
|
||||
stops.addObject().put("name", "终点");
|
||||
return json.writeValueAsString(snapshot);
|
||||
}
|
||||
|
||||
/** 创建本例隔离库中的发车事实。 */
|
||||
private void trip(long id, String number, String purpose, String status, long warehouse, String departed) {
|
||||
jdbc.update("INSERT INTO ll_fleet_trip VALUES(?,?,?,?,?,?,NULL)", id, number, purpose, status, warehouse, departed);
|
||||
}
|
||||
|
||||
/** 构造范围固定的只读查询。 */
|
||||
private TripOperationsModels.Query query(Long warehouse, String status) {
|
||||
return new TripOperationsModels.Query(1, 20, Instant.parse("2026-10-01T00:00:00Z"),
|
||||
Instant.parse("2026-10-03T00:00:00Z"), warehouse, null, null, status);
|
||||
}
|
||||
|
||||
/** 关闭成本查询,聚焦发车事实的持久化和投影。 */
|
||||
private TripOperationsService service() {
|
||||
return new TripOperationsService(repository, new ObjectMapper().registerModule(new JavaTimeModule()), mock(FleetTripCostService.class));
|
||||
}
|
||||
}
|
||||
+27
@@ -11,6 +11,33 @@ import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class TripOperationsServiceTest {
|
||||
/** 照片直送没有仓内装载;与普通出库一起查询时仍保留真实车次及箱数。 */
|
||||
@Test void photoLooseboardDepartureDoesNotBreakTheWholeReport() {
|
||||
var repo=mock(TripOperationsRepository.class);
|
||||
var service=new TripOperationsService(repo,new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class));
|
||||
String snapshot="""
|
||||
{"trip":{"tripNo":"TR-PHOTO","tripPurpose":"DELIVERY","departedAt":"2026-10-01T00:00:00Z",
|
||||
"vehiclePlate":"PHOTO-PLATE","stops":[{"name":"取货地"},{"name":"目的仓","destinationKey":"FBA:ONT8"}]}}
|
||||
""";
|
||||
var query=new TripOperationsModels.Query(1,20,Instant.parse("2026-10-01T00:00:00Z"),Instant.parse("2026-10-03T00:00:00Z"),null,null,null,null);
|
||||
var photo=new TripOperationsRepository.Departure("TR-PHOTO","IN_TRANSIT",null,snapshot,null,"{\"taskType\":\"PHOTO_LOOSEBOARD\",\"packages\":6}");
|
||||
when(repo.departures(query)).thenReturn(List.of(photo,new TripOperationsRepository.Departure("TR-A","COMPLETED",null,DEPARTURE,null)));
|
||||
var result=service.report(query,false);
|
||||
assertThat(result.page().records()).hasSize(2);
|
||||
assertThat(result.summary().trips()).isEqualTo(2);
|
||||
assertThat(result.summary().ownPackages()).isEqualTo(17);
|
||||
assertThat(result.summary().physicalPallets()).isEqualTo(1);
|
||||
var row=result.page().records().get(0);
|
||||
assertThat(row.physicalPallets()).isNull();assertThat(row.palletSpaces()).isNull();
|
||||
assertThat(row.destinations().get(0).label()).isEqualTo("目的仓");
|
||||
// 普通车次仍不能因缺少装载而被静默当成照片直送。
|
||||
assertThatThrownBy(()->service.project(new TripOperationsRepository.Departure("TR-PHOTO","IN_TRANSIT",null,snapshot,null)))
|
||||
.hasMessageContaining("缺少装载");
|
||||
for(String quantity:List.of("null","0","-1","1.5","\"6\"")) {
|
||||
assertThatThrownBy(()->service.project(new TripOperationsRepository.Departure("TR-PHOTO","IN_TRANSIT",null,snapshot,null,
|
||||
"{\"taskType\":\"PHOTO_LOOSEBOARD\",\"packages\":"+quantity+"}"))).hasMessageContaining("实际箱数");
|
||||
}
|
||||
}
|
||||
static final String DEPARTURE="""
|
||||
{"trip":{"tripNo":"TR-A","departedAt":"2026-10-01T00:00:00Z","vehiclePlate":"OLD-PLATE","driverName":"发车司机","warehouseName":"起运仓"},
|
||||
"confirmedLoad":{"cargo":[
|
||||
|
||||
+19
@@ -32,6 +32,7 @@ class InboundPlanControllerTest {
|
||||
@Mock InboundFbaAppointmentService appointmentService;
|
||||
@Mock ContainerInboundPlanConversionCoordinator containerConversion;
|
||||
@Mock com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow;
|
||||
@Mock com.baiyue.lastleg.inboundplan.pda.service.InboundReceivingReconciliationService receivingReconciliation;
|
||||
private InboundPlanController controller;
|
||||
|
||||
@BeforeEach
|
||||
@@ -41,6 +42,24 @@ class InboundPlanControllerTest {
|
||||
ReflectionTestUtils.setField(controller, "appointmentService", appointmentService);
|
||||
ReflectionTestUtils.setField(controller, "containerConversion", containerConversion);
|
||||
ReflectionTestUtils.setField(controller, "inboundWorkflow", inboundWorkflow);
|
||||
ReflectionTestUtils.setField(controller, "receivingReconciliation", receivingReconciliation);
|
||||
}
|
||||
|
||||
/** 平台统一核对短少或超收,客户范围、操作人与请求键原样交给事务服务。 */
|
||||
@Test void platformConfirmsReceivingDifferenceWithExplicitScopeAndActor() {
|
||||
var request = new com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation.Confirm(
|
||||
"100", "snapshot", "现场核对", List.of(), List.of());
|
||||
controller.receivingReconciliation(platform(), "IP-1", "100");
|
||||
verify(receivingReconciliation).snapshot("100", "IP-1");
|
||||
controller.confirmReceivingReconciliation(platform(), "IP-1", "key", request);
|
||||
verify(receivingReconciliation).confirm("100", "IP-1", "7", "key", request);
|
||||
var merchant = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of());
|
||||
assertThatThrownBy(() -> controller.receivingReconciliation(merchant, "IP-1", "100"))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
assertThatThrownBy(() -> controller.confirmReceivingReconciliation(merchant, "IP-1", "key", request))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
assertThatThrownBy(() -> controller.receivingReconciliation(platform(), "IP-1", "0"))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
}
|
||||
|
||||
/** 短少确认只允许平台指定具体客户,并保留真实操作人。 */
|
||||
|
||||
+3
-2
@@ -37,11 +37,12 @@ class ReceivingContainerControllerTest {
|
||||
ReflectionTestUtils.setField(controller, "service", service);
|
||||
}
|
||||
|
||||
/** 候选包含正式柜次号,操作账号仍由已鉴权的平台作业员身份确定。 */
|
||||
@Test
|
||||
void candidatesDerivesOperatorFromPlatformWarehousePickerIdentity() {
|
||||
when(service.candidates("42", 900L)).thenReturn(List.of(new ReceivingContainerResponses.Candidate(
|
||||
"BOOKING-1", "CONT-1", "WAIT_SIGN", 2, 2, 0, 2, 2, 0,
|
||||
List.of("ONT8"), 4, 20, 0, true, true, null, null, false)));
|
||||
"BOOKING-1", "CONT-1", "US-LAX-01", "洛杉矶一号仓", "WAIT_SIGN", 2, 2, 0, 2, 2, 0,
|
||||
List.of("ONT8"), 4, 20, 0, true, true, null, null, false, "GC202611060001")));
|
||||
|
||||
controller.candidates(picker());
|
||||
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundReceivingReconciliationMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** 使用真实 Mapper 校验整柜范围、历史完成会话和独立实盘取数。 */
|
||||
class InboundReceivingReconciliationPersistenceTest {
|
||||
/** 同来源柜次同仓跨客户共同核对,复用柜号、其他仓及不匹配来源均排除。 */
|
||||
@Test
|
||||
void scopeIncludesOtherCustomersOnlyWithinTheSameBookingContainerAndWarehouse() throws Exception {
|
||||
var fixture = new Fixture();
|
||||
|
||||
assertThat(fixture.mapper.scopePlans("101", "IP-10", false))
|
||||
.extracting(InboundPlan::getId).containsExactly(10L, 20L);
|
||||
assertThat(fixture.mapper.scopePlans("202", "IP-20", false))
|
||||
.extracting(InboundPlan::getId).containsExactly(10L, 20L);
|
||||
var lockedPlans = fixture.transaction.execute(status -> fixture.mapper.scopePlans("101", "IP-10", true));
|
||||
assertThat(lockedPlans).extracting(InboundPlan::getId).containsExactly(10L, 20L);
|
||||
}
|
||||
|
||||
/** 必须先定位指定客户的真实计划,空客户、其他客户和缺失柜次均不能扩大范围。 */
|
||||
@Test
|
||||
void scopeRequiresTheSelectedCustomerAndANonblankBooking() throws Exception {
|
||||
var fixture = new Fixture();
|
||||
|
||||
assertThat(fixture.mapper.scopePlans(null, "IP-10", false)).isEmpty();
|
||||
assertThat(fixture.mapper.scopePlans("", "IP-10", false)).isEmpty();
|
||||
assertThat(fixture.mapper.scopePlans("202", "IP-10", false)).isEmpty();
|
||||
assertThat(fixture.mapper.scopePlans("101", "MISSING", false)).isEmpty();
|
||||
assertThat(fixture.mapper.scopePlans("101", "IP-70", false)).isEmpty();
|
||||
assertThat(fixture.mapper.scopePlans("101", "IP-80", false)).isEmpty();
|
||||
assertThat(fixture.mapper.scopePlans("101", "IP-90", false)).isEmpty();
|
||||
}
|
||||
|
||||
/** 已完成会话释放活动键后,仍按柜次、柜号和仓定位,并能在事务中锁定。 */
|
||||
@Test
|
||||
void completedSessionCanBeLocatedAndLockedAfterItsActiveKeysAreCleared() throws Exception {
|
||||
var fixture = new Fixture();
|
||||
var originalTime = LocalDateTime.of(2026, 11, 6, 10, 0);
|
||||
var latestTime = originalTime.plusHours(1);
|
||||
fixture.session(100L, "BOOK-1", "CONT-1", 9L, "COMPLETED", originalTime);
|
||||
fixture.session(110L, "BOOK-1", "CONT-1", 9L, "COMPLETED", latestTime);
|
||||
fixture.session(111L, "BOOK-1", "CONT-1", 9L, "COMPLETED_WITH_DIFFERENCE", latestTime);
|
||||
fixture.session(120L, "BOOK-2", "CONT-1", 9L, "COMPLETED", latestTime.plusHours(1));
|
||||
fixture.session(130L, "BOOK-1", "CONT-1", 10L, "COMPLETED", latestTime.plusHours(1));
|
||||
fixture.session(140L, "BOOK-1", "CONT-2", 9L, "COMPLETED", latestTime.plusHours(1));
|
||||
fixture.session(150L, "book-1", "CONT-1", 9L, "COMPLETED", latestTime.plusHours(1));
|
||||
|
||||
ReceivingContainerSession session = fixture.transaction.execute(status ->
|
||||
fixture.mapper.session("BOOK-1", "CONT-1", 9L, true));
|
||||
|
||||
assertThat(session).isNotNull();
|
||||
assertThat(session.getId()).isEqualTo(111L);
|
||||
assertThat(session.getStatus()).isEqualTo("COMPLETED_WITH_DIFFERENCE");
|
||||
assertThat(session.getActiveOperatorKey()).isNull();
|
||||
assertThat(session.getActiveContainerKey()).isNull();
|
||||
assertThat(session.getCompletedAt()).isEqualTo(latestTime);
|
||||
assertThat(fixture.mapper.session("BOOK-1", "CONT-1", 9L, false).getId()).isEqualTo(111L);
|
||||
assertThat(fixture.mapper.session("MISSING", "CONT-1", 9L, false)).isNull();
|
||||
assertThat(fixture.mapper.session(null, "CONT-1", 9L, false)).isNull();
|
||||
}
|
||||
|
||||
/** 独立实盘保留超申报的完整数量及明确零记录,其他会话同码托盘不能混入。 */
|
||||
@Test
|
||||
void physicalCountsRetainActualAndExplicitZeroWithinTheirOriginalSession() throws Exception {
|
||||
var fixture = new Fixture();
|
||||
fixture.db.update("INSERT INTO ll_receiving_fba_pallet_count VALUES (2,111,'FBA:CONT-1:ONT8','PAL-2',0)");
|
||||
fixture.db.update("INSERT INTO ll_receiving_fba_pallet_count VALUES (1,111,'FBA:CONT-1:ONT8','PAL-1',22)");
|
||||
fixture.db.update("INSERT INTO ll_receiving_fba_pallet_count VALUES (3,222,'FBA:CONT-1:ONT8','PAL-1',66)");
|
||||
|
||||
var physical = fixture.mapper.physicalCounts(111L);
|
||||
|
||||
assertThat(physical).extracting(ReceivingFbaPalletCount::getId).containsExactly(1L, 2L);
|
||||
assertThat(physical).extracting(ReceivingFbaPalletCount::getActualPackages).containsExactly(22, 0);
|
||||
assertThat(physical).extracting(ReceivingFbaPalletCount::getSessionId).containsOnly(111L);
|
||||
assertThat(fixture.mapper.physicalCounts(999L)).isEmpty();
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
final JdbcTemplate db;
|
||||
final TransactionTemplate transaction;
|
||||
final InboundReceivingReconciliationMapper mapper;
|
||||
|
||||
/** 仅建立本次生产 SQL 所需列,使用真实 MyBatis 和独立 READ_COMMITTED 事务。 */
|
||||
Fixture() throws Exception {
|
||||
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:receiving-reconciliation-"
|
||||
+ UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000", "sa", "");
|
||||
db = new JdbcTemplate(dataSource);
|
||||
transaction = new TransactionTemplate(new DataSourceTransactionManager(dataSource));
|
||||
transaction.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
|
||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,plan_no VARCHAR(128),"
|
||||
+ "customer_id VARCHAR(128),source_order_id BIGINT,container_no VARCHAR(64),"
|
||||
+ "overseas_warehouse_id BIGINT,status VARCHAR(32))");
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),"
|
||||
+ "source_container_booking_id VARCHAR(128))");
|
||||
db.execute("CREATE TABLE ll_receiving_container_session(id BIGINT PRIMARY KEY,"
|
||||
+ "source_container_booking_id VARCHAR(128),container_no VARCHAR(64),"
|
||||
+ "overseas_warehouse_id BIGINT,status VARCHAR(32),active_operator_key VARCHAR(255),"
|
||||
+ "active_container_key VARCHAR(255),created_at TIMESTAMP,completed_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_receiving_fba_pallet_count(id BIGINT PRIMARY KEY,session_id BIGINT,"
|
||||
+ "group_key VARCHAR(255),pallet_code VARCHAR(128),actual_packages INT)");
|
||||
plan(20L, "202", "BOOK-1", "CONT-1", 9L);
|
||||
plan(10L, "101", "BOOK-1", "CONT-1", 9L);
|
||||
plan(30L, "101", "BOOK-2", "CONT-1", 9L);
|
||||
plan(40L, "101", "BOOK-1", "CONT-1", 10L);
|
||||
plan(50L, "101", "BOOK-1", "CONT-2", 9L);
|
||||
plan(60L, "101", "book-1", "CONT-1", 9L);
|
||||
plan(70L, "101", "BOOK-1", "CONT-1", 9L);
|
||||
db.update("UPDATE ll_order SET customer_id='202' WHERE id=1070");
|
||||
plan(80L, "101", null, "CONT-1", 9L);
|
||||
plan(90L, "101", " ", "CONT-1", 9L);
|
||||
var configuration = new MybatisConfiguration();
|
||||
configuration.setMapUnderscoreToCamelCase(true);
|
||||
configuration.addMapper(InboundReceivingReconciliationMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(dataSource);
|
||||
factory.setConfiguration(configuration);
|
||||
factory.afterPropertiesSet();
|
||||
mapper = new SqlSessionTemplate(factory.getObject()).getMapper(InboundReceivingReconciliationMapper.class);
|
||||
}
|
||||
|
||||
/** 插入真实客户与来源订单的计划关系,测试只通过正式 Mapper 查询范围。 */
|
||||
private void plan(long id, String customerId, String bookingId, String containerNo, long warehouseId) {
|
||||
db.update("INSERT INTO ll_order VALUES (?,?,?)", id + 1000, customerId, bookingId);
|
||||
db.update("INSERT INTO ll_inbound_plan VALUES (?,?,?,?,?,?,?)", id, "IP-" + id,
|
||||
customerId, id + 1000, containerNo, warehouseId, "PENDING_RECONCILIATION");
|
||||
}
|
||||
|
||||
/** 历史完成会话的活动唯一键为空,原始完成时间仍可作为核对证据。 */
|
||||
private void session(long id, String bookingId, String containerNo, long warehouseId,
|
||||
String status, LocalDateTime createdAt) {
|
||||
db.update("INSERT INTO ll_receiving_container_session VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
id, bookingId, containerNo, warehouseId, status, null, null, createdAt, createdAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.service;
|
||||
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation.*;
|
||||
import jakarta.validation.Validation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.IntStream;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** 请求层接受当前完整核对范围,数量守恒与只读事实继续由服务校验。 */
|
||||
class InboundReceivingReconciliationRequestTest {
|
||||
/** 多票多商品的完整矩阵不能被任意一万行上限阻塞。 */
|
||||
@Test void acceptsCompleteLargeReconciliationSnapshot() {
|
||||
List<Quantity> rows = IntStream.range(0, 10400).mapToObj(index ->
|
||||
new Quantity("100", "IP-1", 3L, 10L + index / 400, "SKU-" + index % 400, 0)).toList();
|
||||
List<Count> pallets = IntStream.range(0, 26).mapToObj(index -> new Count(10L + index, 0)).toList();
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
assertThat(factory.getValidator().validate(new Confirm("100", "snapshot", "逐托复核", pallets, rows))).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/** 空行必须在进入业务事务前拒绝,不能触发空引用错误。 */
|
||||
@Test void rejectsNullListElements() {
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
var validator = factory.getValidator();
|
||||
assertThat(validator.validate(new Confirm("100", "snapshot", "现场复核", Arrays.asList((Count) null), List.of())))
|
||||
.isNotEmpty();
|
||||
assertThat(validator.validate(new Confirm("100", "snapshot", "现场复核", List.of(), Arrays.asList((Quantity) null))))
|
||||
.isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/** 已完成只读整托可以超过普通FBA单托上限,服务仍禁止改写这些数量。 */
|
||||
@Test void acceptsReadonlySourceQuantityAboveBulkLimit() {
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
assertThat(factory.getValidator().validate(new Confirm("100", "snapshot", "现场复核",
|
||||
List.of(new Count(10L, 80)), List.of(new Quantity("100", "IP-1", 3L, 10L, "SKU-1", 80)))))
|
||||
.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/** 原因必填,负数清点或SKU数量均在请求层拒绝。 */
|
||||
@Test void rejectsNegativeCountsAndMissingReason() {
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
assertThat(factory.getValidator().validate(new Confirm("100", "snapshot", " ",
|
||||
List.of(new Count(10L, -1)), List.of(new Quantity("100", "IP-1", 3L, 10L, "SKU-1", -1)))))
|
||||
.hasSize(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.service;
|
||||
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation.*;
|
||||
import com.baiyue.lastleg.inboundplan.entity.*;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.*;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku;
|
||||
import com.baiyue.lastleg.inboundplan.pda.repository.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.*;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InboundReceivingReconciliationServiceTest {
|
||||
@Mock InboundReceivingReconciliationRepository reconciliation;
|
||||
@Mock PdaWorkflowRepository repository;
|
||||
@Mock InboundStockReceiptService stock;
|
||||
@Mock InboundDispatchReceiptGateway dispatch;
|
||||
InboundReceivingReconciliationService service;
|
||||
InboundPlan plan;
|
||||
InboundPdaTask task;
|
||||
InboundPlanLine line;
|
||||
ReceivingContainerSession session;
|
||||
List<InboundPlanPallet> pallets;
|
||||
List<InboundPlanPalletAllocation> allocations;
|
||||
final LocalDateTime receivedAt = LocalDateTime.of(2026, 11, 6, 11, 28);
|
||||
|
||||
/** 现场5加20箱,申报内仅分配5加16箱,另有一个明确待核对的预留托盘。 */
|
||||
@BeforeEach void setup() {
|
||||
service = new InboundReceivingReconciliationService();
|
||||
ReflectionTestUtils.setField(service, "reconciliation", reconciliation);
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
ReflectionTestUtils.setField(service, "stockReceipts", stock);
|
||||
ReflectionTestUtils.setField(service, "dispatchReceipts", dispatch);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
||||
plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setCustomerId("100");
|
||||
plan.setSourceOrderId(9L); plan.setContainerNo("BOX-1"); plan.setOverseasWarehouseId(99L);
|
||||
plan.setStatus("PENDING_RECONCILIATION"); plan.setVersion(5);
|
||||
LastlegOrder order = new LastlegOrder(); order.setId(9L); order.setCustomerId("100");
|
||||
order.setCustomerName("核对客户"); order.setSourceContainerBookingId("BOOKING-1");
|
||||
order.setOrderType("FIRST_LEG"); order.setDestinationType("FBA");
|
||||
task = new InboundPdaTask(); task.setId(2L); task.setPlanId(1L); task.setCustomerId("100");
|
||||
task.setStatus("PENDING_RECONCILIATION"); task.setCompletedAt(receivedAt); task.setVersion(4);
|
||||
task.setUnloadPhotoFileIds("[10]"); task.setUnloadPhotoUploadedAt(receivedAt);
|
||||
line = new InboundPlanLine(); line.setId(3L); line.setPlanId(1L); line.setCustomerId("100");
|
||||
line.setOrderId(9L); line.setInboundCode("IN-1"); line.setFbaWarehouseCode("ACY2"); line.setRequiredPackageCount(21);
|
||||
session = new ReceivingContainerSession(); session.setId(50L); session.setVersion(4);
|
||||
session.setSourceContainerBookingId("BOOKING-1"); session.setContainerNo("BOX-1");
|
||||
session.setOverseasWarehouseId(99L); session.setStatus("COMPLETED_WITH_DIFFERENCE"); session.setCompletedAt(receivedAt);
|
||||
pallets = new ArrayList<>(); allocations = new ArrayList<>();
|
||||
for (int i = 0; i < 3; i++) {
|
||||
InboundPlanPallet pallet = new InboundPlanPallet(); pallet.setId(10L+i); pallet.setPlanId(1L);
|
||||
pallet.setCustomerId("100"); pallet.setPalletNo("PALLET-"+i); pallet.setActualPackageCount(i == 0 ? 5 : i == 1 ? 16 : 0);
|
||||
pallet.setWarehousePalletId(100L+i); pallet.setWarehouseLocationId(200L+i); pallet.setWarehouseResourceLockId(300L+i);
|
||||
pallet.setDestinationType("FBA"); pallet.setDestinationWarehouseCode("ACY2"); pallet.setVersion(1); pallets.add(pallet);
|
||||
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(20L+i);
|
||||
allocation.setCustomerId("100"); allocation.setPlanId(1L); allocation.setLineId(3L); allocation.setPalletId(pallet.getId());
|
||||
allocation.setPlannedPackageCount(7); allocation.setCountedPackageCount(pallet.getActualPackageCount());
|
||||
allocation.setPickedPackageCount(pallet.getActualPackageCount()); allocation.setVersion(1); allocations.add(allocation);
|
||||
}
|
||||
lenient().when(reconciliation.plans(eq("100"), eq("IP-1"), anyBoolean())).thenReturn(List.of(plan));
|
||||
when(repository.order(9L)).thenReturn(order);
|
||||
lenient().when(reconciliation.session(eq("BOOKING-1"), eq("BOX-1"), eq(99L), anyBoolean())).thenReturn(session);
|
||||
lenient().when(repository.task("100",1L)).thenReturn(task);
|
||||
lenient().when(repository.lockTask("100",1L)).thenReturn(task);
|
||||
when(repository.lines(1L)).thenReturn(List.of(line)); when(repository.pallets(1L)).thenReturn(pallets);
|
||||
when(repository.allocations(1L)).thenReturn(allocations);
|
||||
for (int i=0; i<3; i++) {
|
||||
lenient().when(repository.lockAllocation(1L,20L+i)).thenReturn(allocations.get(i));
|
||||
lenient().when(repository.lockPallet(1L,10L+i)).thenReturn(pallets.get(i));
|
||||
}
|
||||
when(repository.frozenSkus("100",1L,3L)).thenReturn(List.of(new FrozenSku("SKU-1",21)));
|
||||
when(repository.skuReceipts("100",1L)).thenReturn(List.of(sku(10L,"SKU-1",5),sku(11L,"SKU-1",16)));
|
||||
lenient().when(reconciliation.physicalCounts(50L)).thenReturn(List.of(physical("PALLET-0",5), physical("PALLET-1",20)));
|
||||
}
|
||||
|
||||
/** 页面保留21箱归属,独立展示25箱实盘,唯一SKU预填完整实盘。 */
|
||||
@Test void exposesPhysicalTwentyFiveSeparatelyFromAttributedTwentyOne() {
|
||||
Snapshot snapshot = service.snapshot("100","IP-1");
|
||||
assertTrue(snapshot.canConfirm()); assertEquals(21,snapshot.expectedPackageCount());
|
||||
assertEquals(21,snapshot.attributedPackageCount()); assertEquals(25,snapshot.countedPackageCount());
|
||||
assertEquals(List.of(5,20,0), snapshot.receipts().stream().map(Receipt::receivedPackageCount).toList());
|
||||
verifyNoInteractions(stock,dispatch);
|
||||
}
|
||||
|
||||
/** 超收按25箱入库,原申报21与原现场时间保留,同时交接运输并记录完整前后证据。 */
|
||||
@Test void confirmsOverReceiptAndReplaysWithoutDuplicatingStock() {
|
||||
Confirm request = request(service.snapshot("100","IP-1"), 5,20,0);
|
||||
Result result = service.confirm("100","IP-1","admin","key",request);
|
||||
assertEquals("COMPLETED",result.status()); assertEquals(25,result.confirmedPackageCount());
|
||||
assertEquals(21,line.getRequiredPackageCount()); assertEquals(receivedAt,task.getCompletedAt());
|
||||
assertEquals(20,allocations.get(1).getPickedPackageCount()); assertEquals(20,pallets.get(1).getActualPackageCount());
|
||||
var sequence = inOrder(dispatch,reconciliation,stock);
|
||||
sequence.verify(dispatch).lock(List.of(1L));
|
||||
sequence.verify(reconciliation).session("BOOKING-1","BOX-1",99L,true);
|
||||
sequence.verify(stock).stockInReconciled(eq("100"),eq(plan),eq(task),eq(List.of(line)),anyList(),anyList(),eq(receivedAt));
|
||||
sequence.verify(dispatch).complete(eq(List.of(1L)),eq("admin"),any());
|
||||
var audit = ArgumentCaptor.forClass(InboundPdaOperation.class); verify(repository).insert(audit.capture());
|
||||
assertTrue(audit.getValue().getRequestJson().contains("sourceFacts"));
|
||||
assertTrue(audit.getValue().getRequestJson().contains("现场核实数量"));
|
||||
when(repository.operation("100","key")).thenReturn(audit.getValue());
|
||||
assertEquals(result,service.confirm("100","IP-1","admin","key",request));
|
||||
verify(stock,times(1)).stockInReconciled(anyString(),any(),any(),anyList(),anyList(),anyList(),any());
|
||||
assertThrows(ResponseStatusException.class,()->service.confirm("100","IP-1","other-admin","key",request));
|
||||
}
|
||||
|
||||
/** 清点误录可在明确原因下更正为21箱,旧实盘完整留在审计中。 */
|
||||
@Test void correctsPhysicalEntryAndCompletesAtTwentyOne() {
|
||||
Result result=service.confirm("100","IP-1","admin","key",request(service.snapshot("100","IP-1"),5,16,0));
|
||||
assertEquals(21,result.confirmedPackageCount()); assertEquals("COMPLETED",result.status());
|
||||
var saved=ArgumentCaptor.forClass(ReceivingFbaPalletCount.class);
|
||||
verify(reconciliation,times(3)).savePhysical(saved.capture(),anyBoolean());
|
||||
assertEquals(List.of(5,16,0),saved.getAllValues().stream().map(ReceivingFbaPalletCount::getActualPackages).toList());
|
||||
}
|
||||
|
||||
/** 短少与全无实收都可确认,预留托盘明确写零,不伪造库存箱数。 */
|
||||
@Test void confirmsShortageIncludingExplicitZero() {
|
||||
Result result=service.confirm("100","IP-1","admin","key",request(service.snapshot("100","IP-1"),0,0,0));
|
||||
assertEquals("PARTIAL_INBOUND",result.status()); assertEquals(0,result.confirmedPackageCount());
|
||||
assertTrue(allocations.stream().allMatch(row->row.getCountedPackageCount()==0&&row.getPickedPackageCount()==0));
|
||||
verify(reconciliation).replaceSkuReceipts("100",1L,List.of());
|
||||
verify(dispatch).complete(eq(List.of(1L)),eq("admin"),any());
|
||||
}
|
||||
|
||||
/** 每托归属必须与确认清点守恒,拒绝用21箱分配掩盖25箱实盘。 */
|
||||
@Test void rejectsUnattributedOverReceiptBeforeAnyWrite() {
|
||||
Snapshot snapshot=service.snapshot("100","IP-1"); Confirm good=request(snapshot,5,20,0);
|
||||
Confirm bad=new Confirm("100",snapshot.snapshotHash(),"现场核实数量",good.pallets(),request(snapshot,5,16,0).receipts());
|
||||
assertThrows(ResponseStatusException.class,()->service.confirm("100","IP-1","admin","key",bad));
|
||||
verifyNoInteractions(stock); verify(reconciliation,never()).savePhysical(any(),anyBoolean());
|
||||
}
|
||||
|
||||
/** 旧快照不能覆盖另一操作的最新清点。 */
|
||||
@Test void rejectsStaleSnapshot() {
|
||||
Snapshot snapshot=service.snapshot("100","IP-1"); Confirm good=request(snapshot,5,20,0);
|
||||
session.setVersion(5);
|
||||
assertThrows(ResponseStatusException.class,()->service.confirm("100","IP-1","admin","key",good));
|
||||
verifyNoInteractions(stock); verify(reconciliation,never()).replaceSkuReceipts(anyString(),anyLong(),anyList());
|
||||
}
|
||||
|
||||
/** 多SKU必须显式核对,不将总箱数自动猜分给商品。 */
|
||||
@Test void explicitSkuShortageCannotBeOffsetByAnotherSkuOverReceipt() {
|
||||
when(repository.frozenSkus("100",1L,3L)).thenReturn(List.of(new FrozenSku("SKU-1",10),new FrozenSku("SKU-2",11)));
|
||||
when(repository.skuReceipts("100",1L)).thenReturn(List.of(sku(10L,"SKU-1",5),sku(11L,"SKU-2",16)));
|
||||
Snapshot snapshot=service.snapshot("100","IP-1");
|
||||
assertEquals(21,snapshot.receipts().stream().mapToInt(Receipt::receivedPackageCount).sum());
|
||||
List<Quantity> quantities=snapshot.receipts().stream().map(row -> new Quantity(row.customerId(),row.planNo(),row.lineId(),row.palletId(),row.skuCode(),
|
||||
row.palletId()==10L&&row.skuCode().equals("SKU-1")?5:
|
||||
row.palletId()==11L&&row.skuCode().equals("SKU-1")?4:
|
||||
row.palletId()==11L&&row.skuCode().equals("SKU-2")?16:0)).toList();
|
||||
Confirm request=new Confirm("100",snapshot.snapshotHash(),"逐商品核实",request(snapshot,5,20,0).pallets(),quantities);
|
||||
Result result=service.confirm("100","IP-1","admin","key",request);
|
||||
assertEquals("PARTIAL_INBOUND",result.status()); assertEquals(25,result.confirmedPackageCount());
|
||||
}
|
||||
|
||||
/** 非本人范围和缺失原会话不能进入确认。 */
|
||||
@Test void rejectsForeignScopeAndMissingSession() {
|
||||
assertThrows(ResponseStatusException.class,()->service.snapshot("200","IP-1"));
|
||||
when(reconciliation.session(eq("BOOKING-1"),eq("BOX-1"),eq(99L),anyBoolean())).thenReturn(null);
|
||||
Snapshot snapshot=service.snapshot("100","IP-1"); assertFalse(snapshot.canConfirm());
|
||||
assertThrows(ResponseStatusException.class,()->service.confirm("100","IP-1","admin","key",request(snapshot,5,20,0)));
|
||||
verifyNoInteractions(stock);
|
||||
}
|
||||
|
||||
/** 原现场仍在收货时禁止后台抢先入库。 */
|
||||
@Test void blocksActiveOnsiteSession() {
|
||||
session.setStatus("RECEIVING"); assertFalse(service.snapshot("100","IP-1").canConfirm());
|
||||
}
|
||||
|
||||
/** 已完成的普通地址票据从真实库存凭证恢复,不能因缺少批量收货行阻塞本柜核对。 */
|
||||
@Test void completedOverseasOrderKeepsItsRealStockImmutable() {
|
||||
addCompletedPlan(false);
|
||||
Snapshot snapshot = service.snapshot("100", "IP-1");
|
||||
Receipt fixed = snapshot.receipts().stream().filter(row -> row.planNo().equals("IP-2")).findFirst().orElseThrow();
|
||||
assertEquals(8, fixed.receivedPackageCount());
|
||||
assertFalse(fixed.editable());
|
||||
Result result = service.confirm("100", "IP-1", "admin", "key", request(snapshot, 5, 20, 0));
|
||||
assertEquals("COMPLETED", result.status());
|
||||
assertEquals(List.of("IP-1"), result.reconciledPlanNos());
|
||||
verify(stock, never()).stockInReconciled(eq("200"), any(), any(), anyList(), anyList(), anyList(), any());
|
||||
}
|
||||
|
||||
/** 同目的仓一件代发的原整托独立冻结,普通票不能把额外实收写入该托。 */
|
||||
@Test void completedDropshipPalletCannotReceiveOrdinaryFbaCargo() {
|
||||
addCompletedPlan(true);
|
||||
Snapshot snapshot = service.snapshot("100", "IP-1");
|
||||
Pallet dropship = snapshot.pallets().stream().filter(row -> row.palletId().equals(64L)).findFirst().orElseThrow();
|
||||
assertTrue(dropship.groupKey().startsWith("DROPSHIP:"));
|
||||
assertFalse(dropship.editable());
|
||||
assertTrue(snapshot.receipts().stream().filter(row -> row.palletId().equals(64L))
|
||||
.allMatch(row -> row.planNo().equals("IP-2") && !row.editable()));
|
||||
assertEquals("COMPLETED", service.confirm("100", "IP-1", "admin", "key", request(snapshot, 5, 20, 0)).status());
|
||||
}
|
||||
|
||||
/** 清点量变化时逐来源及整托体积同步采用现有单箱体积口径。 */
|
||||
@Test void recalculatesActualVolumesFromConfirmedCounts() {
|
||||
line.setVolumeCbm(new java.math.BigDecimal("2.1000"));
|
||||
service.confirm("100", "IP-1", "admin", "key", request(service.snapshot("100", "IP-1"), 5, 20, 0));
|
||||
assertEquals(new java.math.BigDecimal("2.0000"), allocations.get(1).getActualVolumeCbm());
|
||||
assertEquals(new java.math.BigDecimal("2.0000"), pallets.get(1).getActualVolumeCbm());
|
||||
assertEquals(new java.math.BigDecimal("0.0000"), pallets.get(2).getActualVolumeCbm());
|
||||
}
|
||||
|
||||
/** 按三托单 SKU 样本生成完整确认请求,其他只读票据采用原事实。 */
|
||||
private Confirm request(Snapshot snapshot,int first,int second,int third) {
|
||||
Map<Long,Integer> counts=Map.of(10L,first,11L,second,12L,third);
|
||||
return new Confirm("100",snapshot.snapshotHash(),"现场核实数量",
|
||||
snapshot.pallets().stream().map(row->new Count(row.palletId(),counts.getOrDefault(row.palletId(), row.countedPackageCount()))).toList(),
|
||||
snapshot.receipts().stream().map(row->new Quantity(row.customerId(),row.planNo(),row.lineId(),row.palletId(),row.skuCode(),counts.getOrDefault(row.palletId(), row.receivedPackageCount()))).toList());
|
||||
}
|
||||
|
||||
/** 准备同柜已结束的另一客户票据,其库存来源不可被本次普通实收改写。 */
|
||||
private void addCompletedPlan(boolean dropship) {
|
||||
InboundPlan other = new InboundPlan(); other.setId(60L); other.setPlanNo("IP-2"); other.setCustomerId("200");
|
||||
other.setSourceOrderId(69L); other.setContainerNo("BOX-1"); other.setOverseasWarehouseId(99L);
|
||||
other.setStatus("COMPLETED"); other.setVersion(2);
|
||||
LastlegOrder order = new LastlegOrder(); order.setId(69L); order.setCustomerId("200");
|
||||
order.setOrderType(dropship ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG");
|
||||
order.setDestinationType(dropship ? "FBA" : "OVERSEAS");
|
||||
InboundPdaTask completed = new InboundPdaTask(); completed.setId(62L); completed.setPlanId(60L);
|
||||
completed.setCustomerId("200"); completed.setStatus("COMPLETED"); completed.setCompletedAt(receivedAt);
|
||||
InboundPlanLine otherLine = new InboundPlanLine(); otherLine.setId(63L); otherLine.setPlanId(60L);
|
||||
otherLine.setOrderId(69L); otherLine.setRequiredPackageCount(8); otherLine.setDestinationType(order.getDestinationType());
|
||||
otherLine.setDestinationWarehouseId("DEST-2"); otherLine.setFbaWarehouseCode(dropship ? "ACY2" : null);
|
||||
InboundPlanPallet pallet = new InboundPlanPallet(); pallet.setId(64L); pallet.setPlanId(60L);
|
||||
pallet.setCustomerId("200"); pallet.setPalletNo("OTHER-PALLET"); pallet.setActualPackageCount(8);
|
||||
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(65L);
|
||||
allocation.setPlanId(60L); allocation.setLineId(63L); allocation.setPalletId(64L);
|
||||
allocation.setCountedPackageCount(8); allocation.setPickedPackageCount(8);
|
||||
InboundStockReceipt stock = new InboundStockReceipt(); stock.setAllocationId(65L); stock.setLineId(63L);
|
||||
stock.setSkuCode(dropship ? "MIXED" : "SKU-2"); stock.setReceivedPackages(8L);
|
||||
when(reconciliation.plans(eq("100"), eq("IP-1"), anyBoolean())).thenReturn(List.of(plan, other));
|
||||
when(repository.order(69L)).thenReturn(order);
|
||||
when(repository.task("200", 60L)).thenReturn(completed);
|
||||
when(repository.lockTask("200", 60L)).thenReturn(completed);
|
||||
when(repository.lines(60L)).thenReturn(List.of(otherLine));
|
||||
when(repository.pallets(60L)).thenReturn(List.of(pallet));
|
||||
when(repository.allocations(60L)).thenReturn(List.of(allocation));
|
||||
when(repository.lockAllocation(60L, 65L)).thenReturn(allocation);
|
||||
when(repository.lockPallet(60L, 64L)).thenReturn(pallet);
|
||||
when(repository.frozenSkus("200", 60L, 63L)).thenReturn(List.of(new FrozenSku("SKU-2", 8)));
|
||||
when(reconciliation.stockReceipts("200", 62L)).thenReturn(List.of(stock));
|
||||
}
|
||||
/** 构造已分配到货件的 SKU 数量。 */
|
||||
private ReceivingSkuReceipt sku(long palletId,String skuCode,int packages) {
|
||||
ReceivingSkuReceipt row=new ReceivingSkuReceipt(); row.setLineId(3L); row.setPalletId(palletId);
|
||||
row.setSkuCode(skuCode); row.setReceivedPackages(packages); return row;
|
||||
}
|
||||
/** 构造独立于申报内分配的完整现场清点。 */
|
||||
private ReceivingFbaPalletCount physical(String pallet,int packages) {
|
||||
ReceivingFbaPalletCount row=new ReceivingFbaPalletCount(); row.setId(1000L+pallet.hashCode()); row.setSessionId(50L);
|
||||
row.setGroupKey("FBA:BOX-1:ACY2"); row.setPalletCode(pallet); row.setActualPackages(packages);
|
||||
row.setCreatedAt(receivedAt); row.setUpdatedAt(receivedAt); return row;
|
||||
}
|
||||
}
|
||||
+197
-2
@@ -23,6 +23,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
|
||||
import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
|
||||
import com.baiyue.lastleg.storagerent.service.StorageRentService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -61,18 +62,24 @@ class InboundStockReceiptServiceTest {
|
||||
@Mock WarehouseResourceGateway warehouseGateway;
|
||||
@Mock DropshipOrderService dropshipOrders;
|
||||
@Mock LastlegOrderStatusHistoryMapper orderHistories;
|
||||
@Mock StorageRentService storageRent;
|
||||
private InboundStockReceiptService service;
|
||||
|
||||
@BeforeEach
|
||||
/** 组装库存、资源与逐箱事实的隔离依赖。 */
|
||||
void setUp() {
|
||||
service = new InboundStockReceiptService();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
|
||||
ReflectionTestUtils.setField(service, "storageRent", storageRent);
|
||||
ReflectionTestUtils.setField(service, "orders", orders);
|
||||
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
|
||||
ReflectionTestUtils.setField(service, "receipts", receipts);
|
||||
ReflectionTestUtils.setField(service, "receivingSkuReceipts", receivingSkuReceipts);
|
||||
ReflectionTestUtils.setField(service, "workflowRepository", workflowRepository);
|
||||
var reconciliationRepository = new com.baiyue.lastleg.inboundplan.pda.repository.InboundReceivingReconciliationRepository();
|
||||
ReflectionTestUtils.setField(reconciliationRepository, "orders", orders);
|
||||
ReflectionTestUtils.setField(reconciliationRepository, "stockReceipts", receipts);
|
||||
ReflectionTestUtils.setField(reconciliationRepository, "skus", receivingSkuReceipts);
|
||||
ReflectionTestUtils.setField(service, "reconciliationRepository", reconciliationRepository);
|
||||
ReflectionTestUtils.setField(service, "inventory", inventory);
|
||||
ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway);
|
||||
org.mockito.Mockito.lenient().when(warehouseGateway.resolveLocation(eq(900L), anyLong())).thenAnswer(invocation -> {
|
||||
@@ -82,7 +89,6 @@ class InboundStockReceiptServiceTest {
|
||||
});
|
||||
ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders);
|
||||
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
|
||||
when(receipts.listTask("100", 2L)).thenReturn(List.of());
|
||||
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@@ -476,6 +482,195 @@ class InboundStockReceiptServiceTest {
|
||||
verify(inventory, never()).stockIn(any(), any(), any());
|
||||
}
|
||||
|
||||
/** 明确核对的超收按5加20分别入库,保留21箱申报和原现场计租时间。 */
|
||||
@Test void reconciledOverReceiptStocksAllConfirmedPallets() {
|
||||
Fixture f = fixture(21, 21, 5, 210);
|
||||
f.allocation.setCountedPackageCount(5);
|
||||
var secondAllocation = allocation(32L, 12L, 22L, 20);
|
||||
secondAllocation.setCountedPackageCount(20);
|
||||
var secondPallet = pallet(22L, 2002L);
|
||||
LocalDateTime receivedAt = LocalDateTime.of(2026, 11, 6, 12, 0);
|
||||
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
|
||||
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(
|
||||
skuReceipt(12, 21, "SKU-1", 5), skuReceipt(12, 22, "SKU-1", 20)));
|
||||
when(inventory.stockIn(eq("100"), eq("operator"), any()))
|
||||
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 25, 0, 0, 0, false, false));
|
||||
|
||||
service.stockInReconciled("100", f.plan, f.task, List.of(f.line),
|
||||
List.of(f.allocation, secondAllocation), List.of(f.pallet, secondPallet), receivedAt);
|
||||
|
||||
var commands = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
|
||||
verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), commands.capture());
|
||||
assertEquals(List.of(5L, 20L), commands.getAllValues().stream().map(InventoryCommandRequests.StockIn::packages).toList());
|
||||
var saved = ArgumentCaptor.forClass(InboundStockReceipt.class);
|
||||
verify(receipts, times(2)).insert(saved.capture());
|
||||
assertEquals(List.of(receivedAt, receivedAt), saved.getAllValues().stream().map(InboundStockReceipt::getReceivedAt).toList());
|
||||
verify(storageRent, times(2)).registerInbound(any(InboundStockReceipt.class), eq(f.order));
|
||||
assertEquals(21, f.line.getRequiredPackageCount());
|
||||
assertEquals(21, f.detail.getPackageCount());
|
||||
assertEquals("INBOUND_PLAN:COMPLETED", f.order.getSubOrderStatus());
|
||||
verify(dropshipOrders, never()).activateAfterStockIn(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
/** 零实收可以完成异常核对,不创建库存或计租凭证。 */
|
||||
@Test void reconciledZeroReceiptCompletesAsPartialWithoutStock() {
|
||||
Fixture f = fixture(21, 21, 0, 210);
|
||||
f.allocation.setCountedPackageCount(0);
|
||||
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
|
||||
|
||||
service.stockInReconciled("100", f.plan, f.task, List.of(f.line), List.of(f.allocation),
|
||||
List.of(f.pallet), LocalDateTime.now());
|
||||
|
||||
verify(inventory, never()).stockIn(any(), any(), any());
|
||||
verify(receipts, never()).insert(any(InboundStockReceipt.class));
|
||||
verify(storageRent, never()).registerInbound(any(), any());
|
||||
assertEquals("INBOUND_COMPLETED", f.order.getCurrentStatus());
|
||||
assertEquals("INBOUND_PLAN:PARTIAL_INBOUND", f.order.getSubOrderStatus());
|
||||
}
|
||||
|
||||
/** 一个 SKU 超收不能掩盖另一个 SKU 短少,即使全票总数多于申报。 */
|
||||
@Test void reconciledKnownSkuOverReceiptStillPreservesOtherSkuShortage() {
|
||||
Fixture f = fixture(21, 21, 25, 210);
|
||||
f.allocation.setCountedPackageCount(25);
|
||||
f.detail.setPackageCount(10);
|
||||
when(orderDetails.selectList(any())).thenReturn(List.of(f.detail, detail(202, "SKU-2", 11, 110)));
|
||||
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
|
||||
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(
|
||||
skuReceipt(12, 21, "SKU-1", 16), skuReceipt(12, 21, "SKU-2", 9)));
|
||||
when(inventory.stockIn(eq("100"), eq("operator"), any()))
|
||||
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 25, 0, 0, 0, false, false));
|
||||
|
||||
service.stockInReconciled("100", f.plan, f.task, List.of(f.line), List.of(f.allocation),
|
||||
List.of(f.pallet), LocalDateTime.now());
|
||||
|
||||
verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), any());
|
||||
assertEquals("INBOUND_PLAN:PARTIAL_INBOUND", f.order.getSubOrderStatus());
|
||||
}
|
||||
|
||||
/** 多票分别判断短少,不能把另一票零实收的状态套给已核平的超收订单。 */
|
||||
@Test void reconciledReceiptCompletesEachOrderFromItsOwnSkuFacts() {
|
||||
Fixture f = fixture(21, 21, 25, 210);
|
||||
f.allocation.setCountedPackageCount(25);
|
||||
var emptyLine = new InboundPlanLine();
|
||||
emptyLine.setId(13L); emptyLine.setOrderId(102L); emptyLine.setRequiredPackageCount(10);
|
||||
var emptyOrder = new LastlegOrder();
|
||||
emptyOrder.setId(102L); emptyOrder.setCustomerId("100"); emptyOrder.setCurrentStatus("INBOUND_PLANNED");
|
||||
var emptyAllocation = allocation(32L, 13L, 22L, 0);
|
||||
emptyAllocation.setCountedPackageCount(0);
|
||||
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order, emptyOrder));
|
||||
when(orders.lockById("100", 102L)).thenReturn(emptyOrder);
|
||||
when(orderDetails.selectList(any())).thenReturn(List.of(f.detail), List.of(detail(202L, "SKU-2", 10, 100)));
|
||||
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 25)));
|
||||
when(inventory.stockIn(eq("100"), eq("operator"), any()))
|
||||
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 25, 0, 0, 0, false, false));
|
||||
|
||||
service.stockInReconciled("100", f.plan, f.task, List.of(f.line, emptyLine),
|
||||
List.of(f.allocation, emptyAllocation), List.of(f.pallet, pallet(22L, 2002L)), LocalDateTime.now());
|
||||
|
||||
assertEquals("INBOUND_PLAN:COMPLETED", f.order.getSubOrderStatus());
|
||||
assertEquals("INBOUND_PLAN:PARTIAL_INBOUND", emptyOrder.getSubOrderStatus());
|
||||
verify(inventory).stockIn(eq("100"), eq("operator"), any());
|
||||
}
|
||||
|
||||
/** 缺少明确清点事实不能按零实收入库。 */
|
||||
@Test void reconciledReceiptRejectsUnknownZeroCount() {
|
||||
Fixture f = fixture(21, 21, 0, 210);
|
||||
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
|
||||
|
||||
assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task,
|
||||
List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now()));
|
||||
|
||||
verify(inventory, never()).stockIn(any(), any(), any());
|
||||
}
|
||||
|
||||
/** 异常确认入口不能绕过一件代发必须整托收货的冻结来源。 */
|
||||
@Test void reconciledReceiptRejectsDropshipOrder() {
|
||||
Fixture f = fixture(21, 21, 0, 210);
|
||||
f.allocation.setCountedPackageCount(0);
|
||||
f.order.setOrderType("ONE_CLICK_DROPSHIP");
|
||||
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
|
||||
|
||||
assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task,
|
||||
List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now()));
|
||||
|
||||
verify(inventory, never()).stockIn(any(), any(), any());
|
||||
}
|
||||
|
||||
/** 未明确逐 SKU 实收时不按原申报顺序自动分摊超收。 */
|
||||
@Test void reconciledReceiptRequiresExactSkuFacts() {
|
||||
Fixture f = fixture(21, 21, 25, 210);
|
||||
f.allocation.setCountedPackageCount(25);
|
||||
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
|
||||
|
||||
assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task,
|
||||
List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now()));
|
||||
|
||||
verify(inventory, never()).stockIn(any(), any(), any());
|
||||
}
|
||||
|
||||
/** 异常核对仍不能引入冻结申报以外的 SKU。 */
|
||||
@Test void reconciledReceiptRejectsUnknownSku() {
|
||||
Fixture f = fixture(21, 21, 25, 210);
|
||||
f.allocation.setCountedPackageCount(25);
|
||||
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
|
||||
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "UNKNOWN", 25)));
|
||||
|
||||
assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task,
|
||||
List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now()));
|
||||
|
||||
verify(inventory, never()).stockIn(any(), any(), any());
|
||||
}
|
||||
|
||||
/** 全票总数相等也须拒绝逐托实收与 SKU 实收错配。 */
|
||||
@Test void reconciledReceiptRejectsMismatchedPalletQuantities() {
|
||||
Fixture f = fixture(21, 21, 5, 210);
|
||||
f.allocation.setCountedPackageCount(5);
|
||||
var secondAllocation = allocation(32L, 12L, 22L, 20);
|
||||
secondAllocation.setCountedPackageCount(20);
|
||||
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
|
||||
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(
|
||||
skuReceipt(12, 21, "SKU-1", 6), skuReceipt(12, 22, "SKU-1", 19)));
|
||||
|
||||
assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task,
|
||||
List.of(f.line), List.of(f.allocation, secondAllocation), List.of(f.pallet, pallet(22L, 2002L)), LocalDateTime.now()));
|
||||
|
||||
verify(inventory, never()).stockIn(any(), any(), any());
|
||||
}
|
||||
|
||||
/** 同一份已入账实收可重放,不重复生成库存和计租。 */
|
||||
@Test void reconciledReceiptReplaysOnlyMatchingStockReceipts() {
|
||||
Fixture f = fixture(21, 21, 25, 210);
|
||||
f.allocation.setCountedPackageCount(25);
|
||||
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
|
||||
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 25)));
|
||||
var existing = new InboundStockReceipt();
|
||||
existing.setAllocationId(31L); existing.setLineId(12L); existing.setSkuCode("SKU-1"); existing.setReceivedPackages(25L);
|
||||
when(receipts.listTask("100", 2L)).thenReturn(List.of(existing));
|
||||
|
||||
service.stockInReconciled("100", f.plan, f.task, List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now());
|
||||
|
||||
verify(inventory, never()).stockIn(any(), any(), any());
|
||||
verify(receipts, never()).insert(any(InboundStockReceipt.class));
|
||||
verify(storageRent, never()).registerInbound(any(), any());
|
||||
assertEquals("INBOUND_PLAN:COMPLETED", f.order.getSubOrderStatus());
|
||||
}
|
||||
|
||||
/** 已入账凭证数量变化必须拒绝,不能沿用仅比较 allocation ID 的普通重试分支。 */
|
||||
@Test void reconciledReceiptRejectsChangedStockReceiptQuantity() {
|
||||
Fixture f = fixture(21, 21, 25, 210);
|
||||
f.allocation.setCountedPackageCount(25);
|
||||
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
|
||||
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 25)));
|
||||
var existing = new InboundStockReceipt();
|
||||
existing.setAllocationId(31L); existing.setLineId(12L); existing.setSkuCode("SKU-1"); existing.setReceivedPackages(21L);
|
||||
when(receipts.listTask("100", 2L)).thenReturn(List.of(existing));
|
||||
|
||||
assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task,
|
||||
List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now()));
|
||||
|
||||
verify(inventory, never()).stockIn(any(), any(), any());
|
||||
}
|
||||
|
||||
private Fixture fixture(int required, int planned, int picked, int quantity) {
|
||||
InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setOverseasWarehouseId(900L);
|
||||
InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator");
|
||||
|
||||
+60
@@ -80,6 +80,66 @@ class ReceivingCandidateRepositoryTest {
|
||||
verify(orders,times(2)).selectList(any());verifyNoInteractions(plans,lines);verify(mapper).availableOrders();verifyNoMoreInteractions(mapper);
|
||||
}
|
||||
|
||||
/** 柜次号按稳定来源身份分批读取,缺失的业务快照不能被内部 ID 补位。 */
|
||||
@Test
|
||||
void cabinetNumbersAreBatchedAndMissingSnapshotsStayEmpty() {
|
||||
var mapper = mock(ReceivingCandidateMapper.class);
|
||||
var repository = new ReceivingCandidateRepository(mock(LastlegOrderMapper.class),
|
||||
mock(InboundPlanMapper.class), mock(InboundPlanLineMapper.class), mapper,
|
||||
mock(ReceivingContainerSessionMapper.class));
|
||||
var ids = LongStream.rangeClosed(1, 501).mapToObj(id -> "BOOKING-" + id).toList();
|
||||
when(mapper.cabinetNumbers(any())).thenAnswer(invocation -> {
|
||||
List<String> batch = invocation.getArgument(0);
|
||||
assertThat(batch).hasSizeLessThanOrEqualTo(500);
|
||||
return batch.stream().map(id -> new ReceivingCandidateMapper.CabinetNumber(id,
|
||||
id.equals("BOOKING-501") ? null : " GC-" + id + " ")).toList();
|
||||
});
|
||||
var numbers = repository.cabinetNumbers(ids);
|
||||
assertThat(numbers).hasSize(500).containsEntry("BOOKING-1", "GC-BOOKING-1");
|
||||
assertThat(numbers).doesNotContainKey("BOOKING-501");
|
||||
assertThat(repository.cabinetNumbers(List.of())).isEmpty();
|
||||
verify(mapper, times(2)).cabinetNumbers(any());
|
||||
verifyNoMoreInteractions(mapper);
|
||||
}
|
||||
|
||||
/** 同一物理柜的不同柜次分别显示冻结业务号,旧快照缺号时保持为空。 */
|
||||
@Test
|
||||
void cabinetNumberMapperReadsSnapshotsForReusedContainersAndMissingValues() {
|
||||
var dataSource = new org.h2.jdbcx.JdbcDataSource();
|
||||
dataSource.setURL("jdbc:h2:mem:receiving_cabinet_" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
var jdbc = new org.springframework.jdbc.core.JdbcTemplate(dataSource);
|
||||
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.lastleg.calendar.WarehouseCalendarMapperTest.jsonExtract'");
|
||||
jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.calendar.WarehouseCalendarMapperTest.jsonUnquote'");
|
||||
jdbc.execute("CREATE TABLE ll_order(source_container_booking_id VARCHAR(128),container_no VARCHAR(128),source_event_id VARCHAR(128))");
|
||||
jdbc.execute("CREATE TABLE ll_loading_event_inbox(event_id VARCHAR(128),payload_json VARCHAR(1000))");
|
||||
jdbc.update("INSERT INTO ll_order VALUES ('BOOKING-A','SAME-CONTAINER','E1'),('BOOKING-A','SAME-CONTAINER','E2'),"
|
||||
+ "('BOOKING-B','SAME-CONTAINER','E3'),('BOOKING-C','OLD-CONTAINER','MISSING'),"
|
||||
+ "('BOOKING-D','OLD-CONTAINER','E4'),('BOOKING-E','OLD-CONTAINER','E5')");
|
||||
jdbc.update("INSERT INTO ll_loading_event_inbox VALUES (?,?),(?,?),(?,?),(?,?),(?,?)",
|
||||
"E1", "{\"cabinetNo\":\" GC-A \"}", "E2", "{\"cabinetNo\":\"GC-A\"}",
|
||||
"E3", "{\"cabinetNo\":\"GC-B\"}", "E4", "{\"cabinetNo\":null}", "E5", "{\"cabinetNo\":\" \"}");
|
||||
var configuration = new org.apache.ibatis.session.Configuration(new org.apache.ibatis.mapping.Environment(
|
||||
"test", new org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory(), dataSource));
|
||||
configuration.setMapUnderscoreToCamelCase(true);
|
||||
configuration.addMapper(ReceivingCandidateMapper.class);
|
||||
try (var session = new org.apache.ibatis.session.SqlSessionFactoryBuilder().build(configuration).openSession()) {
|
||||
var repository = new ReceivingCandidateRepository(mock(LastlegOrderMapper.class),
|
||||
mock(InboundPlanMapper.class), mock(InboundPlanLineMapper.class),
|
||||
session.getMapper(ReceivingCandidateMapper.class), mock(ReceivingContainerSessionMapper.class));
|
||||
assertThat(repository.cabinetNumbers(List.of("BOOKING-A", "BOOKING-B", "BOOKING-C", "BOOKING-D", "BOOKING-E")))
|
||||
.containsExactlyInAnyOrderEntriesOf(Map.of("BOOKING-A", "GC-A", "BOOKING-B", "GC-B"));
|
||||
}
|
||||
}
|
||||
|
||||
/** 柜次号只读取尾程已接收的装柜事件快照,查询按稳定柜身份分组。 */
|
||||
@Test
|
||||
void cabinetNumberSqlUsesTheLoadingSnapshotAndBookingOccurrence() throws Exception {
|
||||
var sql = ReceivingCandidateMapper.class.getMethod("cabinetNumbers", List.class)
|
||||
.getAnnotation(org.apache.ibatis.annotations.Select.class).value()[0];
|
||||
assertThat(sql).contains("$.cabinetNo", "ll_loading_event_inbox", "e.event_id=o.source_event_id",
|
||||
"o.source_container_booking_id IN", "GROUP BY o.source_container_booking_id", "#{id}");
|
||||
}
|
||||
|
||||
/** 真实 Mapper 汇总不会把其他客户或其他计划的同号行混入实收量。 */
|
||||
@Test
|
||||
void receivedSqlKeepsCustomerPlanAndLineIdentity() throws Exception {
|
||||
|
||||
+9
@@ -155,6 +155,8 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
new com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository.LineKey("200", second.getId(), secondLine.getId()), 5,
|
||||
new com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository.LineKey("OTHER", first.getId(), firstLine.getId()), 99));
|
||||
org.mockito.Mockito.doReturn(facts).when(candidateRepository).load();
|
||||
when(candidateRepository.cabinetNumbers(List.of("BOOKING-A")))
|
||||
.thenReturn(java.util.Map.of("BOOKING-A", "GC202611060001"));
|
||||
|
||||
var result = service.candidates("7", 100L);
|
||||
|
||||
@@ -163,7 +165,9 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
assertThat(result.get(0).totalPackages()).isEqualTo(20);
|
||||
assertThat(result.get(0).receivedPackages()).isEqualTo(8);
|
||||
assertThat(result.get(0).operable()).isTrue();
|
||||
assertThat(result.get(0).cabinetNo()).isEqualTo("GC202611060001");
|
||||
verify(candidateRepository).load();
|
||||
verify(candidateRepository).cabinetNumbers(List.of("BOOKING-A"));
|
||||
verifyNoInteractions(orders, plans, lines, allocations, shippingGates, warehouse);
|
||||
// 不可提前按仓过滤同柜另一客户,否则会把冲突柜错误显示为本仓可收货。
|
||||
secondOrder.setOverseasWarehouseId("200");
|
||||
@@ -316,6 +320,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
.containsExactly("CONT-A", "CONT-B");
|
||||
}
|
||||
|
||||
/** 同仓候选可打开详情,两个入口返回同一正式柜次号。 */
|
||||
@Test
|
||||
void visibleCandidateWithUnassignedPlanCanOpenDetailInTheSameWarehouse() {
|
||||
InboundPlan unassigned = plan("TSTU0000204");
|
||||
@@ -328,12 +333,16 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
when(orders.selectById(1L)).thenReturn(order);
|
||||
when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference());
|
||||
|
||||
when(candidateRepository.cabinetNumbers(List.of("BOOKING-204")))
|
||||
.thenReturn(java.util.Map.of("BOOKING-204", "GC202611060204"));
|
||||
var candidate = service.candidates("7", 100L).get(0);
|
||||
var detail = service.detail("7", 100L, "TSTU0000204", "BOOKING-204");
|
||||
|
||||
assertThat(candidate.operable()).isTrue();
|
||||
assertThat(detail.overseasWarehouseId()).isEqualTo(100L);
|
||||
assertThat(detail.overseasWarehouseCode()).isEqualTo("SHARED-LAX");
|
||||
assertThat(candidate.cabinetNo()).isEqualTo("GC202611060204");
|
||||
assertThat(detail.cabinetNo()).isEqualTo(candidate.cabinetNo());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
package com.baiyue.lastleg.looseboard;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries;
|
||||
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
|
||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
|
||||
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
|
||||
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
|
||||
import com.baiyue.lastleg.looseboard.port.LooseboardDispatches;
|
||||
import com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository;
|
||||
import com.baiyue.lastleg.looseboard.service.LocalLooseboardService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class LocalLooseboardMerchantStatusTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private LocalLooseboardRepository repository;
|
||||
private LocalLooseboardService service;
|
||||
|
||||
/** 在隔离库中执行真实 Mapper、分页计数和客户服务,不依赖运行环境数据。 */
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
jdbc = new JdbcTemplate(ds);
|
||||
String baseline;
|
||||
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
|
||||
baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
int start = baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (");
|
||||
String ddl = baseline.substring(start, baseline.indexOf(';', start))
|
||||
.replaceAll("(?m)^\\s*(?:UNIQUE KEY|KEY) [^\\n]*\\n?", "");
|
||||
ddl = ddl.substring(0, ddl.indexOf(") ENGINE=")) + ")";
|
||||
jdbc.execute(ddl.replaceAll(",\\s*\\)$", ")"));
|
||||
jdbc.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY, source_order_id BIGINT, customer_id VARCHAR(64))");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY, plan_id BIGINT, customer_id VARCHAR(64))");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_item(plan_pallet_id BIGINT, dispatch_order_id BIGINT, customer_id VARCHAR(64))");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY, status VARCHAR(32), stage VARCHAR(32))");
|
||||
jdbc.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT PRIMARY KEY, transfer_no VARCHAR(64), customer_id VARCHAR(64))");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_pickup_task(transfer_order_id BIGINT, trip_no VARCHAR(64), customer_id VARCHAR(64), status VARCHAR(32))");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip(trip_no VARCHAR(64) PRIMARY KEY, status VARCHAR(32))");
|
||||
var config = new MybatisConfiguration();
|
||||
config.setMapUnderscoreToCamelCase(true);
|
||||
config.addMapper(LocalLooseboardOrderMapper.class);
|
||||
var pagination = new MybatisPlusInterceptor();
|
||||
pagination.addInnerInterceptor(new PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));
|
||||
config.addInterceptor(pagination);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(ds); factory.setConfiguration(config);
|
||||
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LocalLooseboardOrderMapper.class);
|
||||
repository = new LocalLooseboardRepository();
|
||||
ReflectionTestUtils.setField(repository, "orders", mapper);
|
||||
ReflectionTestUtils.setField(repository, "histories", mock(LocalLooseboardHistoryMapper.class));
|
||||
service = new LocalLooseboardService();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
ReflectionTestUtils.setField(service, "fleetPool", mock(FleetPoolOrderQueries.class));
|
||||
ReflectionTestUtils.setField(service, "dispatches", mock(LooseboardDispatches.class));
|
||||
}
|
||||
|
||||
/** 审核及资金状态优先,审核通过后内部仓储阶段统一等待排车。 */
|
||||
@Test
|
||||
void approvedOrdersWaitForDispatchWhileFundingReviewAndTerminalStatesRemainDistinct() {
|
||||
String[] statuses = {"FUNDING_PENDING", "FUNDING_FAILED", "PENDING_ACCEPTANCE", "WAITING_INBOUND",
|
||||
"RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH", "DISPATCHED", "IN_TRANSIT", "COMPLETED", "CANCELLED"};
|
||||
for (int index = 0; index < statuses.length; index++) order(index + 1, "C1", statuses[index]);
|
||||
var waiting = service.page("C1", query(1, 20, "PENDING_DISPATCH"));
|
||||
assertThat(waiting.total()).isEqualTo(4);
|
||||
assertThat(waiting.records()).allMatch(row -> row.status().equals("PENDING_DISPATCH"));
|
||||
for (long id : List.of(1L, 2L, 3L, 8L, 9L, 10L, 11L)) {
|
||||
assertThat(service.merchantDetail("C1", "LB" + id).summary().status()).isEqualTo(statuses[(int) id - 1]);
|
||||
}
|
||||
assertThat(service.detail("C1", "LB4").summary().status()).isEqualTo("WAITING_INBOUND");
|
||||
}
|
||||
|
||||
/** 上门取货到仓依次等待排车、等待取货和取货中,到仓后等待下一段排车。 */
|
||||
@Test
|
||||
void warehousePickupUsesTripAndPickupFactsWithoutCompletingTheDeliveryEarly() {
|
||||
order(1, "C1", "WAITING_INBOUND");
|
||||
assertStatus(1, "PENDING_DISPATCH");
|
||||
jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES(1,'W1','C1')");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip VALUES('T1','PLANNED')");
|
||||
jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'T1','C1','ASSIGNED')");
|
||||
assertStatus(1, "DISPATCHED");
|
||||
jdbc.update("UPDATE ll_fleet_trip SET status='CANCELLED'");
|
||||
assertStatus(1, "PENDING_DISPATCH");
|
||||
jdbc.update("UPDATE ll_fleet_trip SET status='PLANNED'");
|
||||
jdbc.update("UPDATE ll_fleet_pickup_task SET trip_no=NULL,status='WAITING'");
|
||||
assertStatus(1, "PENDING_DISPATCH");
|
||||
jdbc.update("UPDATE ll_fleet_pickup_task SET trip_no='T1',status='ASSIGNED'");
|
||||
jdbc.update("UPDATE ll_fleet_trip SET status='IN_TRANSIT'");
|
||||
assertStatus(1, "PICKING_UP");
|
||||
jdbc.update("UPDATE ll_fleet_pickup_task SET status='PICKED'");
|
||||
assertStatus(1, "PICKING_UP");
|
||||
jdbc.update("UPDATE ll_fleet_pickup_task SET status='ARRIVED'");
|
||||
assertStatus(1, "PENDING_DISPATCH");
|
||||
plan(1, "C1"); dispatch(1, 11, "C1", "ACTIVE", "BEFORE_LOADING");
|
||||
assertStatus(1, "DISPATCHED");
|
||||
jdbc.update("UPDATE ll_dispatch_order SET stage='DELIVERING'");
|
||||
assertStatus(1, "IN_TRANSIT");
|
||||
assertThat(repository.findNo("C1", "LB1").getStatus()).isEqualTo("WAITING_INBOUND");
|
||||
jdbc.update("UPDATE ll_local_looseboard_order SET status='COMPLETED'");
|
||||
assertStatus(1, "COMPLETED");
|
||||
}
|
||||
|
||||
/** 取货关联按完整客户标识匹配,忽略大小写的字段排序和长多字节前缀均不能串用其他客户事实。 */
|
||||
@Test
|
||||
void pickupFactsMatchTheEntireCustomerIdentityAcrossTextComparisonRules() {
|
||||
String customer = "客".repeat(60) + "A";
|
||||
order(1, customer, "WAITING_INBOUND");
|
||||
jdbc.execute("ALTER TABLE ll_warehouse_transfer_order ALTER COLUMN customer_id VARCHAR_IGNORECASE(128)");
|
||||
jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES(1,'W1',?)", customer);
|
||||
jdbc.update("INSERT INTO ll_fleet_trip VALUES('T1','IN_TRANSIT')");
|
||||
jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'T1',?,'PICKED')", customer);
|
||||
assertThat(service.merchantDetail(customer, "LB1").summary().status()).isEqualTo("PICKING_UP");
|
||||
|
||||
for (String otherCustomer : List.of("客".repeat(60) + "a", "客".repeat(60) + "B")) {
|
||||
jdbc.update("UPDATE ll_fleet_pickup_task SET customer_id=?", otherCustomer);
|
||||
assertThat(service.merchantDetail(customer, "LB1").summary().status()).isEqualTo("PENDING_DISPATCH");
|
||||
assertThat(service.page(customer, query(1, 20, "PICKING_UP")).total()).isZero();
|
||||
assertThat(service.page(customer, query(1, 20, "PENDING_DISPATCH")).total()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
/** 取货直送及分车派送按有效排车判断,取消排车和重复托盘关联不污染状态或总数。 */
|
||||
@Test
|
||||
void directDeliveryIgnoresCancelledDispatchesAndKeepsSplitDeliveryInTransit() {
|
||||
order(1, "C1", "WAITING_INBOUND"); plan(1, "C1");
|
||||
dispatch(1, 11, "C1", "CANCELLED", "DELIVERING");
|
||||
assertStatus(1, "PENDING_DISPATCH");
|
||||
dispatch(1, 12, "C1", "ACTIVE", "LOADING");
|
||||
jdbc.update("INSERT INTO ll_dispatch_item VALUES(1,12,'C1')");
|
||||
assertStatus(1, "DISPATCHED");
|
||||
jdbc.update("UPDATE ll_dispatch_order SET stage='DELIVERED' WHERE id=12");
|
||||
dispatch(1, 13, "C1", "ACTIVE", "BEFORE_LOADING");
|
||||
assertStatus(1, "IN_TRANSIT");
|
||||
jdbc.update("UPDATE ll_dispatch_order SET status='CANCELLED'");
|
||||
assertStatus(1, "PENDING_DISPATCH");
|
||||
jdbc.update("UPDATE ll_local_looseboard_order SET status='CANCELLED'");
|
||||
jdbc.update("UPDATE ll_dispatch_order SET status='ACTIVE'");
|
||||
assertStatus(1, "CANCELLED");
|
||||
}
|
||||
|
||||
/** 原单、来源计划、托盘、排车项及取货任务均保持客户隔离。 */
|
||||
@Test
|
||||
void foreignSourceAndPickupFactsCannotAdvanceAnotherCustomersOrder() {
|
||||
order(1, "C1", "WAITING_INBOUND"); order(2, "C2", "WAITING_INBOUND");
|
||||
plan(1, "C2"); dispatch(1, 11, "C2", "ACTIVE", "DELIVERING");
|
||||
assertStatus(1, "PENDING_DISPATCH");
|
||||
jdbc.update("UPDATE ll_inbound_plan SET customer_id='C1'");
|
||||
assertStatus(1, "PENDING_DISPATCH");
|
||||
jdbc.update("UPDATE ll_inbound_plan_pallet SET customer_id='C1'");
|
||||
assertStatus(1, "PENDING_DISPATCH");
|
||||
jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES(1,'W1','C2')");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip VALUES('T1','IN_TRANSIT')");
|
||||
jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'T1','C2','PICKED')");
|
||||
assertStatus(1, "PENDING_DISPATCH");
|
||||
assertThat(repository.merchantStatuses("C2", List.of(1L))).isEmpty();
|
||||
assertThatThrownBy(() -> service.merchantDetail("C2", "LB1"))
|
||||
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
jdbc.update("UPDATE ll_dispatch_item SET customer_id='C1'");
|
||||
assertStatus(1, "IN_TRANSIT");
|
||||
}
|
||||
|
||||
/** 筛选发生在分页之前;筛选总数、列表状态及详情一致并拒绝废弃筛选值。 */
|
||||
@Test
|
||||
void merchantPaginationCountsProjectedStatusAndRejectsUnusedFilters() {
|
||||
order(1, "C1", "WAITING_INBOUND"); order(2, "C1", "RECEIVING");
|
||||
order(3, "C1", "PENDING_ACCEPTANCE"); order(4, "C2", "WAITING_INBOUND");
|
||||
var first = service.page("C1", query(1, 1, " pending_dispatch "));
|
||||
var second = service.page("C1", query(2, 1, "PENDING_DISPATCH"));
|
||||
assertThat(first.total()).isEqualTo(2); assertThat(first.pages()).isEqualTo(2);
|
||||
assertThat(second.total()).isEqualTo(2);
|
||||
assertThat(first.records()).hasSize(1); assertThat(second.records()).hasSize(1);
|
||||
assertThat(first.records().get(0).id()).isNotEqualTo(second.records().get(0).id());
|
||||
for (var row : List.of(first.records().get(0), second.records().get(0))) {
|
||||
assertThat(row.customerId()).isEqualTo("C1");
|
||||
assertThat(row.status()).isEqualTo(service.merchantDetail("C1", row.orderNo()).summary().status());
|
||||
}
|
||||
for (var status : List.of("APPROVED", "REJECTED", "PROCESSING", "WAITING_INBOUND", "PENDING_REVIEW")) {
|
||||
assertThatThrownBy(() -> service.page("C1", query(1, 20, status)))
|
||||
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
}
|
||||
}
|
||||
|
||||
/** 构造有正式来源标识的原单,取送时间保持本地业务时间。 */
|
||||
private void order(long id, String customer, String status) {
|
||||
var order = new LocalLooseboardOrder();
|
||||
order.setId(id); order.setCustomerId(customer); order.setOrderNo("LB" + id);
|
||||
order.setTransferNo("W" + id); order.setLastlegOrderId(id + 1000);
|
||||
order.setOrigin("LA"); order.setDestination("ONT"); order.setDeliveryMode("WAREHOUSE_PICKUP");
|
||||
order.setPickupAt(LocalDateTime.of(2026, 11, 6, 9, 0)); order.setDeliveryAt(order.getPickupAt().plusHours(5));
|
||||
order.setTotalWeightKg(BigDecimal.ZERO); order.setTotalVolumeCbm(BigDecimal.ZERO);
|
||||
order.setCurrency("USD"); order.setStatus(status); order.setIdempotencyKey("K" + id); order.setRequestHash("0".repeat(64));
|
||||
order.setCreatedAt(LocalDateTime.of(2026, 11, 6, 8, 0));
|
||||
repository.insertOrder(order);
|
||||
}
|
||||
|
||||
/** 建立原单到申报托盘的正式链路。 */
|
||||
private void plan(long id, String customer) {
|
||||
jdbc.update("INSERT INTO ll_inbound_plan VALUES(?,?,?)", id, id + 1000, customer);
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,?)", id, id, customer);
|
||||
}
|
||||
|
||||
/** 建立可独立取消或推进的分车排车事实。 */
|
||||
private void dispatch(long pallet, long id, String customer, String status, String stage) {
|
||||
jdbc.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, status, stage);
|
||||
jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,?,?)", pallet, id, customer);
|
||||
}
|
||||
|
||||
/** 每个节点同时核对列表筛选总数和客户详情。 */
|
||||
private void assertStatus(long id, String status) {
|
||||
assertThat(service.merchantDetail("C1", "LB" + id).summary().status()).isEqualTo(status);
|
||||
var page = service.page("C1", query(1, 20, status));
|
||||
assertThat(page.total()).isEqualTo(1);
|
||||
assertThat(page.records().get(0).orderNo()).isEqualTo("LB" + id);
|
||||
}
|
||||
|
||||
/** 用同一路线和日期边界查询客户运输进度。 */
|
||||
private LocalLooseboardRequests.MerchantQuery query(long page, long size, String status) {
|
||||
return new LocalLooseboardRequests.MerchantQuery(page, size, null, "LA", status,
|
||||
LocalDate.of(2026, 11, 6), LocalDate.of(2026, 11, 6));
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -48,6 +48,7 @@ class LocalLooseboardServiceTest {
|
||||
@Mock LastlegOrderMapper lastlegOrders;
|
||||
@Mock LastlegOrderStatusHistoryMapper lastlegHistories;
|
||||
@Mock com.baiyue.lastleg.looseboard.port.LooseboardDispatches dispatches;
|
||||
@Mock com.baiyue.lastleg.wallet.budget.OrderBudgetService budgets;
|
||||
@Spy LastlegOrderNoGenerator lastlegOrderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders();
|
||||
private LocalLooseboardService service;
|
||||
private final Map<String, LocalLooseboardOperation> operationRows = new HashMap<>();
|
||||
@@ -56,6 +57,13 @@ class LocalLooseboardServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new LocalLooseboardService();
|
||||
ReflectionTestUtils.setField(service, "orderBudget", budgets);
|
||||
org.mockito.Mockito.lenient().when(budgets.submit(anyString(),anyString(),anyString(),any(),anyString(),any(),any()))
|
||||
.thenAnswer(call -> ((java.util.function.Supplier<?>)call.getArgument(5)).get());
|
||||
org.mockito.Mockito.lenient().when(budgets.update(anyString(),anyString(),anyString(),any(),anyString(),any()))
|
||||
.thenAnswer(call -> ((java.util.function.Supplier<?>)call.getArgument(5)).get());
|
||||
org.mockito.Mockito.lenient().when(budgets.withOrder(anyString(),anyString(),anyString(),any()))
|
||||
.thenAnswer(call -> ((java.util.function.Supplier<?>)call.getArgument(3)).get());
|
||||
ReflectionTestUtils.setField(service, "dispatches", dispatches);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
|
||||
var repository = new LocalLooseboardRepository();
|
||||
@@ -106,7 +114,7 @@ class LocalLooseboardServiceTest {
|
||||
var detail = service.create("100", "7", create("key-1"));
|
||||
|
||||
assertEquals("100", detail.summary().customerId());
|
||||
assertEquals(LocalLooseboardService.PENDING_ACCEPTANCE, detail.summary().status());
|
||||
assertEquals(LocalLooseboardService.FUNDING_PENDING, detail.summary().status());
|
||||
assertEquals("Alice", detail.summary().originContactName());
|
||||
assertEquals("Bob", detail.summary().destinationContactName());
|
||||
assertEquals("家居用品", detail.summary().productName());
|
||||
|
||||
+12
@@ -53,6 +53,18 @@ class OrderBudgetAuthorizationTest {
|
||||
verify(service).retryOrder("9","trucking","KC1");
|
||||
assertThatThrownBy(()->controller.retry(writer,"procurement","CG1")).isInstanceOf(AccessDeniedException.class);
|
||||
}
|
||||
@Test void looseboardFundingUsesOnlyItsOwnCustomerAndPermissions() {
|
||||
var controller=context.getBean(MerchantOrderBudgetController.class);
|
||||
var service=context.getBean(OrderBudgetService.class);
|
||||
var reader=login(9,"MERCHANT","merchant:cross-border:pallet-shipping:list");
|
||||
controller.readOrder(reader,"looseboard","LB1");verify(service).readOrder("9","looseboard","LB1");
|
||||
assertThatThrownBy(()->controller.retry(reader,"looseboard","LB1")).isInstanceOf(AccessDeniedException.class);
|
||||
var writer=login(9,"MERCHANT","merchant:cross-border:pallet-shipping:create");
|
||||
controller.retry(writer,"looseboard","LB1");verify(service).retryOrder("9","looseboard","LB1");
|
||||
assertThatThrownBy(()->controller.retry(writer,"trucking","LB1")).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(()->controller.readOrder(writer,"procurement","LB1")).isInstanceOf(AccessDeniedException.class);
|
||||
}
|
||||
|
||||
private AuthenticatedUser login(long customer,String app,String permission) {
|
||||
var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission));
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(permission))));return user;
|
||||
|
||||
+120
-6
@@ -12,6 +12,11 @@ import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
import com.baiyue.lastleg.looseboard.mapper.*;
|
||||
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
|
||||
import com.baiyue.lastleg.looseboard.service.LocalLooseboardService;
|
||||
import com.baiyue.lastleg.looseboard.port.LooseboardReceiving;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** 用真实 Mapper 验证客户隔离、持久化及到期/终态释放候选。 */
|
||||
@@ -31,7 +36,7 @@ class OrderBudgetPersistenceTest {
|
||||
var outbox = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_event_outbox \\(.*?;").matcher(sql);
|
||||
assertThat(outbox.find()).isTrue();jdbc.execute(outbox.group());
|
||||
jdbc.execute("CREATE TABLE ll_overseas_procurement_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))");
|
||||
for (String table : new String[]{"ll_us_truck_order", "ll_us_truck_order_pallet", "ll_us_truck_order_operation", "ll_us_truck_order_status_history"}) {
|
||||
for (String table : new String[]{"ll_us_truck_order", "ll_us_truck_order_pallet", "ll_us_truck_order_operation", "ll_us_truck_order_status_history", "ll_local_looseboard_order", "ll_local_looseboard_operation", "ll_local_looseboard_history"}) {
|
||||
var ddl = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS " + table + " \\(.*?;").matcher(sql);
|
||||
assertThat(ddl.find()).isTrue(); jdbc.execute(ddl.group());
|
||||
}
|
||||
@@ -41,6 +46,9 @@ class OrderBudgetPersistenceTest {
|
||||
config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper.class);
|
||||
config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper.class);
|
||||
config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper.class);
|
||||
config.addMapper(LocalLooseboardOrderMapper.class);
|
||||
config.addMapper(LocalLooseboardOperationMapper.class);
|
||||
config.addMapper(LocalLooseboardHistoryMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
||||
sessions = new SqlSessionTemplate(factory.getObject());
|
||||
repository = new OrderBudgetRepository(sessions.getMapper(OrderBudgetMapper.class));
|
||||
@@ -60,10 +68,11 @@ class OrderBudgetPersistenceTest {
|
||||
}
|
||||
/** 两类业务创建失败时,真实预算、订单及Outbox必须整体回滚。 */
|
||||
@Test void bothBusinessesRollbackOrderBudgetAndOutboxTogether() {
|
||||
for (String kind : new String[]{"procurement","trucking"}) {
|
||||
String table = "procurement".equals(kind) ? "ll_overseas_procurement_order" : "ll_us_truck_order";
|
||||
for (String kind : new String[]{"procurement","trucking","looseboard"}) {
|
||||
String table = switch(kind) { case "procurement" -> "ll_overseas_procurement_order"; case "trucking" -> "ll_us_truck_order"; default -> "ll_local_looseboard_order"; };
|
||||
assertThatThrownBy(() -> service.submit("9",kind,"rollback",BigDecimal.TEN,"CNY",() -> {
|
||||
if ("trucking".equals(kind)) jdbc.update("INSERT INTO ll_us_truck_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(1,'9','FAIL','A','B','FUNDING_PENDING','rollback',?)", "a".repeat(64));
|
||||
else if ("looseboard".equals(kind)) jdbc.update("INSERT INTO ll_local_looseboard_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(1,'9','FAIL','A','B','FUNDING_PENDING','rollback',?)", "a".repeat(64));
|
||||
else jdbc.update("INSERT INTO " + table + " VALUES('9','FAIL','FUNDING_PENDING')");
|
||||
throw new IllegalStateException("订单明细写入失败");
|
||||
}, value -> String.valueOf(value))).hasMessageContaining("订单明细写入失败");
|
||||
@@ -74,12 +83,113 @@ class OrderBudgetPersistenceTest {
|
||||
}
|
||||
/** 成功受理原子提交并可同键重放,资金指令不能重复。 */
|
||||
@Test void acceptsAndReplaysWithoutDuplicateFreeze() {
|
||||
for (String kind : new String[]{"procurement","trucking"}) {
|
||||
for (String kind : new String[]{"procurement","trucking","looseboard"}) {
|
||||
service.submit("9",kind,"accept",BigDecimal.TEN,"CNY",()->"ORDER1",v->v);
|
||||
service.submit("9",kind,"accept",BigDecimal.TEN,"CNY",()->"ORDER1",v->v);
|
||||
assertThat(repository.lockOrder("9",kind,"ORDER1").getStatus()).isEqualTo("FREEZE_PENDING");
|
||||
}
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(3);
|
||||
}
|
||||
|
||||
/** 散板原单、资金回执、审核和释放使用真实Mapper,重复请求不重复占款或建履约。 */
|
||||
@Test void looseboardCreationFreezesAndApprovalReleasesTheSameBudget() {
|
||||
var harness=looseboards();var looseboards=harness.orders();var request=looseboardRequest();
|
||||
var created=looseboards.create("9","customer",request);
|
||||
String number=created.summary().orderNo();
|
||||
assertThat(created.summary().status()).isEqualTo("FUNDING_PENDING");
|
||||
assertThatThrownBy(()->looseboards.review("9",number,"reviewer",new LocalLooseboardRequests.Review(0L,"APPROVE","early-review",null)))
|
||||
.hasMessageContaining("冻结成功");
|
||||
looseboards.create("9","customer",request);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(1);
|
||||
var budget=repository.lockOrder("9","looseboard",number);
|
||||
service.handle(truckReceipt(budget,"FREEZE","FROZEN"));
|
||||
var ready=looseboards.detail("9",number);
|
||||
assertThat(ready.summary().status()).isEqualTo("PENDING_ACCEPTANCE");
|
||||
var review=new LocalLooseboardRequests.Review(ready.summary().version(),"APPROVE","review-key",null);
|
||||
assertThat(looseboards.review("9",number,"reviewer",review).summary().status()).isEqualTo("WAITING_INBOUND");
|
||||
looseboards.review("9",number,"reviewer",review);
|
||||
org.mockito.Mockito.verify(harness.receiving(),org.mockito.Mockito.times(1)).approve(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.eq("reviewer"));
|
||||
budget=repository.lockOrder("9","looseboard",number);
|
||||
assertThat(budget.getStatus()).isEqualTo("RELEASE_PENDING");assertThat(budget.getReleaseRequested()).isTrue();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2);
|
||||
service.handle(truckReceipt(budget,"RELEASE","RELEASED"));
|
||||
assertThat(repository.lockOrder("9","looseboard",number).getStatus()).isEqualTo("RELEASED");
|
||||
assertThat(looseboards.create("9","customer",request).summary().orderNo()).isEqualTo(number);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2);
|
||||
}
|
||||
|
||||
/** 冻结失败不能审核,重试仍绑定原单;驳回同键重放只释放一次。 */
|
||||
@Test void looseboardInsufficientFundsCanRetryAndRejectionReleasesOnce() {
|
||||
var harness=looseboards();var looseboards=harness.orders();
|
||||
String number=looseboards.create("9","customer",looseboardRequest()).summary().orderNo();
|
||||
var budget=repository.lockOrder("9","looseboard",number);
|
||||
service.handle(new com.baiyue.common.event.WalletCommandCompletedV1(budget.getCommandEventId(),"FREEZE",9L,OrderBudgetService.BUSINESS_TYPE,
|
||||
budget.getId().toString(),"FAILED",null,budget.getAmountCny(),null,"可用余额和授信额度不足",java.time.Instant.now(),null,null));
|
||||
assertThat(looseboards.detail("9",number).summary().status()).isEqualTo("FUNDING_FAILED");
|
||||
assertThatThrownBy(()->looseboards.review("9",number,"reviewer",new LocalLooseboardRequests.Review(1L,"APPROVE","early",null)))
|
||||
.hasMessageContaining("冻结成功");
|
||||
assertThat(service.readOrder("9","looseboard",number).message()).contains("授信额度不足");
|
||||
service.retryOrder("9","looseboard",number);
|
||||
budget=repository.lockOrder("9","looseboard",number);service.handle(truckReceipt(budget,"FREEZE","FROZEN"));
|
||||
var review=new LocalLooseboardRequests.Review(looseboards.detail("9",number).summary().version(),"REJECT","reject-key","路线不可承运");
|
||||
looseboards.review("9",number,"reviewer",review);looseboards.review("9",number,"reviewer",review);
|
||||
assertThat(looseboards.detail("9",number).summary().status()).isEqualTo("CANCELLED");
|
||||
assertThat(repository.lockOrder("9","looseboard",number).getStatus()).isEqualTo("RELEASE_PENDING");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(3);
|
||||
org.mockito.Mockito.verifyNoInteractions(harness.receiving());
|
||||
}
|
||||
|
||||
/** 取消时冻结在途,成功回执只能触发释放,不能将原单重新改成待审核。 */
|
||||
@Test void looseboardCancellationReleasesLateFreezeWithoutReopeningOrder() {
|
||||
var looseboards=looseboards().orders();String number=looseboards.create("9","customer",looseboardRequest()).summary().orderNo();
|
||||
var cancel=new LocalLooseboardRequests.ChangeStatus(0L,"CANCELLED","cancel-key",null);
|
||||
looseboards.changeStatus("9",number,"operator",cancel);looseboards.changeStatus("9",number,"operator",cancel);
|
||||
var budget=repository.lockOrder("9","looseboard",number);assertThat(budget.getReleaseRequested()).isTrue();
|
||||
service.handle(truckReceipt(budget,"FREEZE","FROZEN"));
|
||||
assertThat(repository.lockOrder("9","looseboard",number).getStatus()).isEqualTo("RELEASE_PENDING");
|
||||
assertThat(looseboards.detail("9",number).summary().status()).isEqualTo("CANCELLED");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2);
|
||||
}
|
||||
|
||||
/** 审核履约创建失败回滚,不提前释放预算或保存已审核状态。 */
|
||||
@Test void looseboardApprovalFailureKeepsOriginalHold() {
|
||||
var harness=looseboards();var looseboards=harness.orders();String number=looseboards.create("9","customer",looseboardRequest()).summary().orderNo();
|
||||
var budget=repository.lockOrder("9","looseboard",number);service.handle(truckReceipt(budget,"FREEZE","FROZEN"));
|
||||
org.mockito.Mockito.when(harness.receiving().approve(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.anyString())).thenThrow(new IllegalStateException("履约创建失败"));
|
||||
assertThatThrownBy(()->looseboards.review("9",number,"reviewer",new LocalLooseboardRequests.Review(1L,"APPROVE","failed-review",null)))
|
||||
.hasMessageContaining("履约创建失败");
|
||||
assertThat(looseboards.detail("9",number).summary().status()).isEqualTo("PENDING_ACCEPTANCE");
|
||||
assertThat(repository.lockOrder("9","looseboard",number).getStatus()).isEqualTo("FROZEN");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(1);
|
||||
}
|
||||
|
||||
private record Looseboards(LocalLooseboardService orders,LooseboardReceiving receiving) {}
|
||||
/** 散板原单及历史使用生产持久化,仅隔离真正的收货履约入口。 */
|
||||
private Looseboards looseboards() {
|
||||
var orders=sessions.getMapper(LocalLooseboardOrderMapper.class);var history=sessions.getMapper(LocalLooseboardHistoryMapper.class);
|
||||
var repo=new com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository();
|
||||
ReflectionTestUtils.setField(repo,"orders",orders);ReflectionTestUtils.setField(repo,"histories",history);
|
||||
ReflectionTestUtils.setField(repo,"operations",sessions.getMapper(LocalLooseboardOperationMapper.class));
|
||||
var looseboards=new LocalLooseboardService();ReflectionTestUtils.setField(looseboards,"repository",repo);
|
||||
ReflectionTestUtils.setField(looseboards,"objectMapper",new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
|
||||
ReflectionTestUtils.setField(looseboards,"orderBudget",service);
|
||||
ReflectionTestUtils.setField(looseboards,"dispatches",org.mockito.Mockito.mock(com.baiyue.lastleg.looseboard.port.LooseboardDispatches.class));
|
||||
ReflectionTestUtils.setField(looseboards,"fleetPool",org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
|
||||
var numbers=org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegOrderNoGenerator.class);
|
||||
org.mockito.Mockito.when(numbers.next("LOOSEBOARD")).thenReturn("LB-BUDGET-1");ReflectionTestUtils.setField(looseboards,"lastlegOrderNoGenerator",numbers);
|
||||
var receiving=org.mockito.Mockito.mock(LooseboardReceiving.class);ReflectionTestUtils.setField(looseboards,"receiving",receiving);
|
||||
org.mockito.Mockito.when(receiving.approve(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.anyString())).thenReturn(new LooseboardReceiving.Created("TRANSFER1",99L));
|
||||
var actualFunding=new com.baiyue.lastleg.procurement.funding.OrderFundingService(
|
||||
new com.baiyue.lastleg.procurement.funding.OrderFundingRepository(null,null,null,null,orders,history));
|
||||
org.mockito.Mockito.doAnswer(call->{actualFunding.apply(call.getArgument(0),call.getArgument(1),call.getArgument(2));return null;})
|
||||
.when(funding).apply(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any());
|
||||
return new Looseboards(looseboards,receiving);
|
||||
}
|
||||
/** 明确金额和币种,避免测试把未填金额冒充零价。 */
|
||||
private LocalLooseboardRequests.Create looseboardRequest() {
|
||||
return new LocalLooseboardRequests.Create("9","FBA",null,"WAREHOUSE_PICKUP",null,null,null,null,"LA","Street A","ONT8",null,
|
||||
"货物",2,10,BigDecimal.TEN,BigDecimal.ONE,new BigDecimal("100.00"),"CNY","发货人","10086","收货人","10010",
|
||||
LocalDateTime.of(2026,11,2,9,0),LocalDateTime.of(2026,11,3,9,0),"原始备注","looseboard-budget-key");
|
||||
}
|
||||
|
||||
/** 本地真实建卡车单,核对冻结、审核释放、回执和重复审核的持久化结果。 */
|
||||
@@ -99,7 +209,7 @@ class OrderBudgetPersistenceTest {
|
||||
org.mockito.Mockito.when(numbers.next("TRUCK")).thenReturn("TRUCK-LOCAL-BUDGET-1");
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(trucks, "orderNoGenerator", numbers);
|
||||
var actualFunding = new com.baiyue.lastleg.procurement.funding.OrderFundingService(
|
||||
new com.baiyue.lastleg.procurement.funding.OrderFundingRepository(null, null, orders, histories));
|
||||
new com.baiyue.lastleg.procurement.funding.OrderFundingRepository(null, null, orders, histories, null, null));
|
||||
org.mockito.Mockito.doAnswer(call -> {
|
||||
actualFunding.apply(call.getArgument(0), call.getArgument(1), call.getArgument(2)); return null;
|
||||
}).when(funding).apply(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
|
||||
@@ -158,7 +268,11 @@ class OrderBudgetPersistenceTest {
|
||||
repository.insert(row(4,"FROZEN"));
|
||||
repository.insert(row(5,"FREEZE_PENDING"));
|
||||
var recentFailure = row(6,"RELEASE_FAILED");recentFailure.setReleaseRequested(true);repository.insert(recentFailure);
|
||||
assertThat(repository.releasable()).extracting(OrderBudget::getId).containsExactly(1L,2L,3L);
|
||||
var acceptedLooseboard=row(7,"FROZEN");acceptedLooseboard.setKind("looseboard");acceptedLooseboard.setOrderNo("LB1");repository.insert(acceptedLooseboard);
|
||||
jdbc.update("INSERT INTO ll_local_looseboard_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(7,'9','LB1','A','B','WAITING_INBOUND','accepted',?)", "a".repeat(64));
|
||||
var pendingLooseboard=row(8,"FROZEN");pendingLooseboard.setKind("looseboard");pendingLooseboard.setOrderNo("LB2");repository.insert(pendingLooseboard);
|
||||
jdbc.update("INSERT INTO ll_local_looseboard_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(8,'9','LB2','A','B','PENDING_ACCEPTANCE','pending',?)", "a".repeat(64));
|
||||
assertThat(repository.releasable()).extracting(OrderBudget::getId).containsExactly(1L,2L,3L,7L);
|
||||
}
|
||||
/** 测试数据与生产预占使用同一 UTC 时间,数据库会话保持东八区以覆盖时区差异。 */
|
||||
private OrderBudget row(long id,String status) {
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ class OrderBudgetServiceTest {
|
||||
assertThat(service.read("9","procurement","key").usable()).isFalse();
|
||||
assertThat(service.read("9","procurement","key").message()).contains("过期");
|
||||
}
|
||||
@ParameterizedTest @ValueSource(strings={"procurement","trucking"})
|
||||
@ParameterizedTest @ValueSource(strings={"procurement","trucking","looseboard"})
|
||||
void reviewReleaseImmediatelyReleasesFrozenBudget(String kind) {
|
||||
row.setKind(kind);
|
||||
row.setOrderNo("CG-1"); row.setStatus("FROZEN");
|
||||
|
||||
+18
@@ -15,6 +15,24 @@ class OrderFundingServiceTest {
|
||||
OrderFundingRepository repository = mock(OrderFundingRepository.class);
|
||||
OrderFundingService service = new OrderFundingService(repository);
|
||||
|
||||
/** 散板成功后进入原待审核状态,失败可重试;重复或取消后的回执不复活原单。 */
|
||||
@Test void looseboardFundingPreservesAuditGateAndIgnoresLateResults() {
|
||||
var budget = budget("looseboard");
|
||||
var order = new com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder();
|
||||
order.setId(1L);order.setCustomerId("9");order.setStatus("FUNDING_PENDING");order.setVersion(0L);order.setRemark("客户备注");
|
||||
when(repository.looseboard("9","NO1")).thenReturn(order);
|
||||
service.apply(budget,"FUNDING_FAILED","可用余额和授信额度不足");
|
||||
assertThat(order.getStatus()).isEqualTo("FUNDING_FAILED");
|
||||
service.apply(budget,"FUNDING_PENDING",null);
|
||||
service.apply(budget,"PENDING_REVIEW",null);
|
||||
service.apply(budget,"PENDING_REVIEW",null);
|
||||
assertThat(order.getStatus()).isEqualTo("PENDING_ACCEPTANCE");
|
||||
assertThat(order.getVersion()).isEqualTo(3L);assertThat(order.getRemark()).isEqualTo("客户备注");
|
||||
order.setStatus("CANCELLED");service.apply(budget,"PENDING_REVIEW",null);
|
||||
assertThat(order.getStatus()).isEqualTo("CANCELLED");
|
||||
verify(repository,times(3)).history(eq(order),anyString(),anyString(),nullable(String.class),any());
|
||||
}
|
||||
|
||||
/** 采购冻结成功后才待审核,重复成功无第二条历史。 */
|
||||
@Test void purchaseSuccessIsIdempotentAndPreservesRemark() {
|
||||
var budget = budget("procurement");
|
||||
|
||||
@@ -136,7 +136,8 @@ public class UnifiedOrderQueryService {
|
||||
OrderEntity order = authority.get(row.orderId());
|
||||
if (order == null || !Objects.equals(order.getTenantId(), row.tenantId())
|
||||
|| !ACTIVE_ORDER_STATUSES.contains(order.getStatus())) continue;
|
||||
String status = order.getStatus() == OrderStatus.SUBMITTED ? "PENDING_REVIEW"
|
||||
// 待审核主状态仍由 Order 决定,提前测量提交产生的独立审核任务须保留。
|
||||
String status = order.getStatus() == OrderStatus.SUBMITTED && !"MEASUREMENT_REVIEW".equals(row.mainStatus()) ? "PENDING_REVIEW"
|
||||
: order.getStatus() == OrderStatus.COMPLETED && !WORKFLOW_STATUSES.contains(row.mainStatus()) ? "COMPLETED" : row.mainStatus();
|
||||
if (status != null) result.add(new FirstlegUnifiedOrderClient.StatusRow(row.tenantId(), row.orderId(), status));
|
||||
}
|
||||
|
||||
@@ -421,7 +421,7 @@ paths:
|
||||
需要平台身份和 platform:order:list;tenantId 省略时按授权平台范围查询全部客户。
|
||||
按统一列表非分页、非 mainStatus 筛选全量去重;忽略主状态和分页,不返回全部数量。
|
||||
订单审核与完成状态优先于履约投影,依赖异常返回业务错误,不能以零代替失败。
|
||||
MEASUREMENT_REVIEW 为独立任务,暂不返回该 key。
|
||||
MEASUREMENT_REVIEW 返回独立测量审核任务数量;待审核订单提前测量并提交审核后可同时计入 PENDING_REVIEW 与 MEASUREMENT_REVIEW。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UnifiedTaxMode'
|
||||
- {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
@@ -1743,7 +1743,7 @@ components:
|
||||
name: mainStatus
|
||||
in: query
|
||||
required: false
|
||||
description: 统一订单主状态及业务页签筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询。WAIT_CUSTOMS_DOCS 为实际装柜后待上传客户报关资料;EXPORT_CUSTOMS 为本票报关资料已上传且实际装柜完成、尚未报关完成或实际开船的汇总,买单票无需客户退税资料,不以点击开始报关为前提;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;生成出库单后进入 OUTBOUND_DOCUMENT,实际出库后进入 DELIVERING,派送完成进入 SIGNED。
|
||||
description: 统一订单主状态及业务页签筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询。WAIT_CUSTOMS_DOCS 为实际装柜后待上传客户报关资料;EXPORT_CUSTOMS 为本票报关资料已上传且实际装柜完成、尚未报关完成或实际开船的汇总,买单票无需客户退税资料,不以点击开始报关为前提;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;尾程排车理货审核通过并生成有效出库单后进入 OUTBOUND_DOCUMENT,不以前置开船节点或清关资料为条件;取消或实际出库后移出,实际出库并发车后进入 DELIVERING,派送完成进入 SIGNED。
|
||||
schema:
|
||||
type: string
|
||||
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW]
|
||||
|
||||
@@ -1012,6 +1012,35 @@ class UnifiedOrderQueryServiceTest {
|
||||
});
|
||||
}
|
||||
|
||||
/** 提前测量提交后同时保留订单待审核和测量审核待办,统计与列表一致。 */
|
||||
@Test
|
||||
void submittedOrderRetainsIndependentMeasurementReviewTask() {
|
||||
var pending = order(OrderStatus.SUBMITTED);
|
||||
when(orders.unifiedStatusOrders(eq(10001L), any(), any())).thenReturn(List.of(pending));
|
||||
when(orders.statusAuthorities(eq(10001L), any())).thenReturn(List.of(pending));
|
||||
when(orders.findByIds(eq(10001L), any())).thenReturn(List.of(pending));
|
||||
when(firstleg.statusRows(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of(
|
||||
new FirstlegUnifiedOrderClient.StatusRow(10001L, 77L, "WAIT_MEASURE"),
|
||||
new FirstlegUnifiedOrderClient.StatusRow(10001L, 77L, "MEASUREMENT_REVIEW"),
|
||||
new FirstlegUnifiedOrderClient.StatusRow(10001L, 77L, "MEASUREMENT_REVIEW"))));
|
||||
var projected = summary("WAIT_MEASURE", null);
|
||||
when(projected.inbound().processStatus()).thenReturn("MEASURE_PENDING");
|
||||
when(firstleg.statusSummaries(eq("internal-token"), any()))
|
||||
.thenReturn(ApiResponse.success(List.of(projected)));
|
||||
|
||||
var counts = service.statusCounts(10001L, query(null));
|
||||
var page = service.page(10001L, query("MEASUREMENT_REVIEW"));
|
||||
|
||||
assertThat(counts.counts()).containsEntry("PENDING_REVIEW", 1L)
|
||||
.containsEntry("MEASUREMENT_REVIEW", 1L).containsEntry("WAIT_MEASURE", 0L);
|
||||
assertThat(page.total()).isEqualTo(1L);
|
||||
assertThat(page.records()).singleElement().satisfies(row -> {
|
||||
assertThat(row.mainStatus()).isEqualTo("PENDING_REVIEW");
|
||||
assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER");
|
||||
assertThat(row.inboundId()).isEqualTo(88L);
|
||||
});
|
||||
}
|
||||
|
||||
/** 错误响应和跨客户投影均失败关闭,不能返回看似可信的零。 */
|
||||
@Test
|
||||
void statusCountsRejectDependencyFailuresAndForeignCustomerRows() {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
const pipeline = (await readFile(new URL('../Jenkinsfile.service', import.meta.url), 'utf8'))
|
||||
@@ -15,6 +19,60 @@ const serviceDockerfile = (await readFile(new URL('../deploy/docker/Dockerfile',
|
||||
const serviceCompose = (await readFile(new URL('../deploy/docker-compose.yml', import.meta.url), 'utf8'))
|
||||
.replaceAll('\r\n', '\n')
|
||||
|
||||
test('TEST rejects old source commits even when the workspace contains newer limits files', {
|
||||
skip: process.platform === 'win32' && !existsSync('C:/Program Files/Git/bin/bash.exe')
|
||||
}, async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'test-memory-source-'))
|
||||
const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash'
|
||||
const guardStart = pipeline.indexOf('if [ "$RESOLVED_ENVIRONMENT" = "test" ]; then')
|
||||
const guardEnd = pipeline.indexOf('if [ "$RESOLVED_ENVIRONMENT" = "production" ]; then', guardStart)
|
||||
assert.ok(guardStart >= 0 && guardEnd > guardStart)
|
||||
assert.ok(guardStart < pipeline.indexOf("stage('Maven 测试与打包')"))
|
||||
const guard = pipeline.slice(guardStart, guardEnd)
|
||||
// 在微型 Git 夹具中验证目标提交本身,避免工作区残留文件误放行旧版本。
|
||||
const git = (...args) => {
|
||||
const result = spawnSync('git', args, { cwd: directory, encoding: 'utf8' })
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
return result.stdout.trim()
|
||||
}
|
||||
// 构建提交沿用正常身份设置,但禁止夹具触发全局签名及提交钩子。
|
||||
const commit = (message) => {
|
||||
git('add', '.')
|
||||
git('-c', 'user.name=Resource Contract', '-c', 'user.email=resource@example.invalid',
|
||||
'-c', 'core.hooksPath=/dev/null', 'commit', '--no-gpg-sign', '-m', message)
|
||||
return git('rev-parse', 'HEAD')
|
||||
}
|
||||
try {
|
||||
git('init')
|
||||
await writeFile(join(directory, 'README.md'), 'Source guard fixture\n')
|
||||
const oldCommit = commit('old source')
|
||||
await mkdir(join(directory, 'deploy/scripts'), { recursive: true })
|
||||
await writeFile(join(directory, 'deploy/docker-compose.test-limits.yml'), 'services: {}\n')
|
||||
await writeFile(join(directory, 'deploy/scripts/deploy-built-service.sh'), '#!/bin/sh\nexit 0\n')
|
||||
const incompleteCommit = commit('limits file without deployment integration')
|
||||
await writeFile(join(directory, 'deploy/scripts/deploy-built-service.sh'), directDeployScript)
|
||||
const protectedCommit = commit('complete memory protection')
|
||||
git('checkout', '--detach', oldCommit)
|
||||
await mkdir(join(directory, 'deploy/scripts'), { recursive: true })
|
||||
await writeFile(join(directory, 'deploy/docker-compose.test-limits.yml'), 'services: {}\n')
|
||||
await writeFile(join(directory, 'deploy/scripts/deploy-built-service.sh'), directDeployScript)
|
||||
for (const [environment, sourceCommit, status] of [
|
||||
['test', oldCommit, 1], ['test', incompleteCommit, 1],
|
||||
['test', protectedCommit, 0], ['production', oldCommit, 0]
|
||||
]) {
|
||||
const result = spawnSync(bash, ['-c', guard], {
|
||||
cwd: directory, encoding: 'utf8', timeout: 10000,
|
||||
env: { ...process.env, RESOLVED_ENVIRONMENT: environment, RESOLVED_SOURCE_COMMIT: sourceCommit }
|
||||
})
|
||||
assert.equal(result.status, status, result.stderr)
|
||||
if (status !== 0) assert.match(result.stderr, /目标 TEST 提交缺少完整内存保护/)
|
||||
}
|
||||
} finally {
|
||||
assert.equal(dirname(resolve(directory)), resolve(tmpdir()))
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('both pipelines reject the placeholder registry from failed build 662 before Maven or Docker', () => {
|
||||
for (const source of [coordinatorPipeline, pipeline]) {
|
||||
assert.match(source, /string\(name: 'IMAGE_REPOSITORY', defaultValue: params\.IMAGE_REPOSITORY \?: ''/,
|
||||
|
||||
Reference in New Issue
Block a user