Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e27881808d | ||
|
|
ba0bf9ebb7 | ||
|
|
8d211acf5b | ||
|
|
5e9cebcf85 | ||
|
|
0fe1d10566 | ||
|
|
50687c851d | ||
|
|
2ae8e7ce94 | ||
|
|
e31dcf80f9 | ||
|
|
2573bd92cb | ||
|
|
20b2a7e058 | ||
|
|
c0cd2c2183 | ||
|
|
46d359241f | ||
|
|
57fcf295b1 | ||
|
|
5950806924 | ||
|
|
4dc3d1752e | ||
|
|
f60f44793f | ||
|
|
d4eb0ef023 | ||
|
|
29bbd85ed6 | ||
|
|
f010dcc4e0 | ||
|
|
7151222836 | ||
|
|
4825f782a3 | ||
|
|
a7ca6b98f4 |
@@ -383,11 +383,12 @@ public class FbaPricingRepository {
|
||||
.set(InboundFeeEntity::getConfirmedAt, now));
|
||||
}
|
||||
|
||||
/** 确认费用时保留物流主状态,一件代发待组托也允许确认。 */
|
||||
public int markInboundConfirmed(long tenantId, long inboundId) {
|
||||
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
||||
.in(InboundPricingEntity::getProcessStatus,
|
||||
List.of("FEE_CONFIRMING", "WAIT_PALLET", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"))
|
||||
List.of("FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"))
|
||||
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
|
||||
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
|
||||
}
|
||||
|
||||
+6
-4
@@ -9,11 +9,12 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ContainerCalendarSourceMapper {
|
||||
/** 按明确来源柜读取与预约日历相同的到港事实,实际到港优先。 */
|
||||
/** 按明确来源柜读取到港事实;目的港优先显示名称,未填名称时显示已保存代码。 */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
||||
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
|
||||
c.vessel_name vesselName,c.voyage_no voyageNo,
|
||||
COALESCE(NULLIF(TRIM(c.destination_port_name),''),NULLIF(TRIM(c.destination_port_code),'')) destinationPortName,
|
||||
c.destination_warehouse_code destinationWarehouseCode,
|
||||
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
|
||||
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
|
||||
@@ -28,10 +29,11 @@ public interface ContainerCalendarSourceMapper {
|
||||
""")
|
||||
List<ContainerCalendarSource> byIds(@Param("ids") List<Long> ids);
|
||||
|
||||
/** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */
|
||||
/** 选出到港、缺日期和可能还柜的有效出仓柜;目的港名称为空时展示代码,最终日期由服务复核。 */
|
||||
@Select("""
|
||||
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
||||
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
|
||||
c.vessel_name vesselName,c.voyage_no voyageNo,
|
||||
COALESCE(NULLIF(TRIM(c.destination_port_name),''),NULLIF(TRIM(c.destination_port_code),'')) destinationPortName,
|
||||
c.destination_warehouse_code destinationWarehouseCode,
|
||||
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
|
||||
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
|
||||
|
||||
@@ -40,7 +40,7 @@ paths:
|
||||
containerNo: {type: string, nullable: true}
|
||||
vesselName: {type: string, nullable: true}
|
||||
voyageNo: {type: string, nullable: true}
|
||||
destinationPortName: {type: string, nullable: true}
|
||||
destinationPortName: {type: string, nullable: true, description: 目的港展示值,优先名称,名称为空时使用已保存的港口代码,两者均为空时返回null}
|
||||
destinationWarehouseCode: {type: string, nullable: true}
|
||||
arrivalAt: {type: string, format: date-time, nullable: true}
|
||||
arrivalType: {type: string, enum: [ACTUAL, ESTIMATED]}
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
||||
import com.baiyue.firstleg.mapper.InboundPricingMapper;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** 通过真实 Mapper 执行费用状态更新,防止业务层放行但持久化条件遗漏作业状态。 */
|
||||
class FbaPricingFeeTransitionTest {
|
||||
private SqlSession session;
|
||||
private JdbcTemplate sql;
|
||||
private FbaPricingRepository repository;
|
||||
|
||||
/** 每项测试使用独立内存表,保留真实的客户和逻辑删除条件。 */
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
var ds = new JdbcDataSource();
|
||||
ds.setURL("jdbc:h2:mem:fee-transition-" + UUID.randomUUID() + ";MODE=MySQL");
|
||||
var config = new MybatisConfiguration();
|
||||
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
|
||||
config.addMapper(InboundPricingMapper.class);
|
||||
session = new MybatisSqlSessionFactoryBuilder().build(config).openSession(true);
|
||||
session.getConnection();
|
||||
sql = new JdbcTemplate(ds);
|
||||
sql.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT," +
|
||||
"process_status VARCHAR(32),fee_status VARCHAR(32),deleted BOOLEAN DEFAULT FALSE)");
|
||||
repository = new FbaPricingRepository();
|
||||
ReflectionTestUtils.setField(repository, "inbounds", session.getMapper(InboundPricingMapper.class));
|
||||
}
|
||||
|
||||
/** 关闭最后一个连接后销毁本项测试的内存数据库。 */
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (session != null) session.close();
|
||||
}
|
||||
|
||||
/** 费用确认和发送不得提前完成一件代发组托或回退已有履约进度。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
|
||||
void confirmsAndSendsWithoutAdvancingPalletization(String processStatus) {
|
||||
insertInbound(processStatus, "CALCULATED");
|
||||
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isEqualTo(1);
|
||||
assertState(processStatus, "CONFIRMED");
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isEqualTo(1);
|
||||
assertState("FEE_CONFIRMING".equals(processStatus) ? "WAIT_PALLET" : processStatus, "SENT");
|
||||
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isZero();
|
||||
}
|
||||
|
||||
/** 待组托只补费用确认资格,不能绕过费用快照的前置状态。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"UNPRICED", "CONFIRMED", "SENT", "VOIDED"})
|
||||
void rejectsConfirmationUnlessFeeIsCalculated(String feeStatus) {
|
||||
insertInbound("WAIT_PALLETIZE", feeStatus);
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
assertState("WAIT_PALLETIZE", feeStatus);
|
||||
}
|
||||
|
||||
/** 非确认阶段、其他客户和已删除入库单仍不可确认。 */
|
||||
@Test
|
||||
void preservesProcessCustomerAndDeletionGuards() {
|
||||
insertInbound("WAIT_MEASURE", "CALCULATED");
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
sql.update("UPDATE fl_inbound_order SET process_status='WAIT_PALLETIZE' WHERE id=88");
|
||||
assertThat(repository.markInboundConfirmed(20002L, 88L)).isZero();
|
||||
sql.update("UPDATE fl_inbound_order SET deleted=TRUE WHERE id=88");
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
assertState("WAIT_PALLETIZE", "CALCULATED");
|
||||
}
|
||||
|
||||
/** 模拟发送前作业状态发生变化,旧状态条件须拒绝写入。 */
|
||||
@Test
|
||||
void rejectsSendWithStaleProcessStatus() {
|
||||
insertInbound("WAIT_SPLIT", "CONFIRMED");
|
||||
assertThat(repository.markInboundFeeSent(10001L, 88L, "WAIT_PALLETIZE")).isZero();
|
||||
assertState("WAIT_SPLIT", "CONFIRMED");
|
||||
}
|
||||
|
||||
/** 准备单个客户的入库单,不调用共享环境。 */
|
||||
private void insertInbound(String processStatus, String feeStatus) {
|
||||
sql.update("INSERT INTO fl_inbound_order(id,tenant_id,process_status,fee_status) VALUES(88,10001,?,?)",
|
||||
processStatus, feeStatus);
|
||||
}
|
||||
|
||||
/** 同时核对物理作业状态与费用状态,防止用改主流程掩盖发送问题。 */
|
||||
private void assertState(String processStatus, String feeStatus) {
|
||||
assertThat(sql.queryForObject("SELECT process_status FROM fl_inbound_order WHERE id=88", String.class))
|
||||
.isEqualTo(processStatus);
|
||||
assertThat(sql.queryForObject("SELECT fee_status FROM fl_inbound_order WHERE id=88", String.class))
|
||||
.isEqualTo(feeStatus);
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -194,8 +194,9 @@ class FbaPricingRepositoryRuleListTest {
|
||||
.contains(10001L, 88L, "FEE_CONFIRMING", "CONFIRMED", "SENT", "WAIT_PALLET");
|
||||
}
|
||||
|
||||
/** 费用确认状态集覆盖待组托及后续履约阶段。 */
|
||||
@Test
|
||||
void feeConfirmationAllowsPostLoadingInboundStatuses() {
|
||||
void feeConfirmationAllowsPalletizationAndPostLoadingInboundStatuses() {
|
||||
String source;
|
||||
try {
|
||||
source = Files.readString(Path.of(
|
||||
@@ -205,7 +206,7 @@ class FbaPricingRepositoryRuleListTest {
|
||||
throw new AssertionError(exception);
|
||||
}
|
||||
assertThat(source)
|
||||
.contains("List.of(\"FEE_CONFIRMING\", \"WAIT_PALLET\", \"WAIT_SPLIT\", \"LOADING\", \"LOADED\", \"DELIVERED\")");
|
||||
.contains("List.of(\"FEE_CONFIRMING\", \"WAIT_PALLET\", \"WAIT_PALLETIZE\", \"WAIT_SPLIT\", \"LOADING\", \"LOADED\", \"DELIVERED\")");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -22,6 +22,9 @@ import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.R
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
@@ -223,11 +226,13 @@ class FbaPricingServiceTest {
|
||||
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("待分柜边界确认费用后仍保持主流程状态")
|
||||
void confirmsAtWaitSplitWithoutRollingBackProcessStatus() {
|
||||
/** 待组托、待分柜确认费用均不推进物流主流程。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"WAIT_PALLETIZE", "WAIT_SPLIT"})
|
||||
@DisplayName("待组托或待分柜确认费用后仍保持主流程状态")
|
||||
void confirmsWithoutRollingBackProcessStatus(String processStatus) {
|
||||
InboundPricingEntity inbound = inbound("10.000", "1.0000");
|
||||
inbound.setProcessStatus("WAIT_SPLIT");
|
||||
inbound.setProcessStatus(processStatus);
|
||||
inbound.setFeeStatus("CALCULATED");
|
||||
InboundFeeEntity fee = savedFee("CALCULATED");
|
||||
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee);
|
||||
@@ -244,7 +249,7 @@ class FbaPricingServiceTest {
|
||||
FeeSnapshotResponse response = service.confirm(
|
||||
TENANT, OPERATOR, INBOUND, "confirm-wait-split");
|
||||
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
|
||||
assertThat(response.nextProcessStatus()).isEqualTo(processStatus);
|
||||
verify(repository).markInboundConfirmed(TENANT, INBOUND);
|
||||
}
|
||||
|
||||
@@ -436,11 +441,13 @@ class FbaPricingServiceTest {
|
||||
verify(repository, never()).insertFee(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 待组托发送费用保留主状态,重复请求不重复生成费用事件。 */
|
||||
@ParameterizedTest
|
||||
@CsvSource({"FEE_CONFIRMING,WAIT_PALLET", "WAIT_PALLETIZE,WAIT_PALLETIZE"})
|
||||
@DisplayName("重复发送只返回首次结果且不会重复写Outbox")
|
||||
void sendIsIdempotentAndDoesNotDuplicateOutbox() {
|
||||
void sendIsIdempotentAndDoesNotDuplicateOutbox(String processStatus, String expectedStatus) {
|
||||
InboundPricingEntity inbound = inbound("10", "1");
|
||||
inbound.setProcessStatus("FEE_CONFIRMING");
|
||||
inbound.setProcessStatus(processStatus);
|
||||
inbound.setFeeStatus("CONFIRMED");
|
||||
InboundFeeEntity confirmed = savedFee("CONFIRMED");
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
@@ -448,7 +455,7 @@ class FbaPricingServiceTest {
|
||||
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_SEND", "send-once"))
|
||||
.thenReturn(null, completed("FEE_SEND", 501L));
|
||||
when(repository.sendFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1);
|
||||
when(repository.markInboundFeeSent(TENANT, INBOUND, "FEE_CONFIRMING")).thenReturn(1);
|
||||
when(repository.markInboundFeeSent(TENANT, INBOUND, processStatus)).thenReturn(1);
|
||||
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
|
||||
when(repository.findFee(TENANT, 501L)).thenReturn(confirmed);
|
||||
InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00"));
|
||||
@@ -459,8 +466,8 @@ class FbaPricingServiceTest {
|
||||
FeeSnapshotResponse first = service.send(TENANT, OPERATOR, INBOUND, "send-once");
|
||||
FeeSnapshotResponse repeated = service.send(TENANT, OPERATOR, INBOUND, "send-once");
|
||||
|
||||
assertThat(first.nextProcessStatus()).isEqualTo("WAIT_PALLET");
|
||||
assertThat(repeated.nextProcessStatus()).isEqualTo("WAIT_PALLET");
|
||||
assertThat(first.nextProcessStatus()).isEqualTo(expectedStatus);
|
||||
assertThat(repeated.nextProcessStatus()).isEqualTo(expectedStatus);
|
||||
verify(financeOutbox, times(1)).publishInbound(
|
||||
eq(FinanceFeeSourceOutboxService.PUBLISHED), eq(TENANT), eq(INBOUND),
|
||||
eq(501L), eq(1), eq(NOW));
|
||||
|
||||
+20
-4
@@ -36,8 +36,9 @@ class MerchantInboundBillingServiceTest {
|
||||
@MockBean private InboundFeeDetailService feeDetails;
|
||||
@Resource private MerchantInboundBillingService service;
|
||||
|
||||
/** 测量审核通过并发送费用后,待组托与后续履约阶段均可查看客户费用。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
|
||||
@ValueSource(strings = {"WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
|
||||
void shouldKeepPublishedBillingVisibleAfterFulfillmentProgresses(String processStatus) {
|
||||
InboundOrderEntity inbound = inbound(processStatus, "APPROVED", "SENT");
|
||||
InboundOrderDetail detail = new InboundOrderDetail(mock(com.baiyue.firstleg.dto.InboundOrderSummary.class),
|
||||
@@ -84,9 +85,11 @@ class MerchantInboundBillingServiceTest {
|
||||
verify(orders).findByOrderId(10001L, 77L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleCase02() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_SPLIT", "PENDING", "SENT");
|
||||
/** 待组托不绕过客户费用可见性的测量审核条件。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"WAIT_PALLETIZE", "WAIT_SPLIT"})
|
||||
void shouldHandleCase02(String processStatus) {
|
||||
InboundOrderEntity inbound = inbound(processStatus, "PENDING", "SENT");
|
||||
when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound);
|
||||
|
||||
assertThatThrownBy(() -> service.find(10001L, 77L))
|
||||
@@ -105,6 +108,19 @@ class MerchantInboundBillingServiceTest {
|
||||
verify(pricing, never()).latest(20002L, 88L);
|
||||
}
|
||||
|
||||
/** 客户可在组托前确认已审核并发送的费用,继续使用当前费用版本和幂等键。 */
|
||||
@Test
|
||||
void confirmsPublishedFeeWhileWaitingForPalletization() {
|
||||
when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound("WAIT_PALLETIZE", "APPROVED", "SENT"));
|
||||
FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class);
|
||||
when(pricing.confirmByCustomer(10001L, 7L, "客户成员", 88L, 3, "confirm-palletize"))
|
||||
.thenReturn(fee);
|
||||
|
||||
assertThat(service.confirmFee(10001L, 7L, "客户成员", 77L, 3, "confirm-palletize"))
|
||||
.isSameAs(fee);
|
||||
verify(pricing).confirmByCustomer(10001L, 7L, "客户成员", 88L, 3, "confirm-palletize");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleCase04() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_SPLIT", "APPROVED", "SENT");
|
||||
|
||||
+37
-2
@@ -11,6 +11,41 @@ import java.time.LocalDateTime;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ContainerCalendarSourceMapperTest {
|
||||
/** 两个日历来源入口均优先展示港口名称,仅填代码时仍可回显,缺失时保持空值。 */
|
||||
@Test
|
||||
void displaysDestinationNameOrSavedCodeAcrossBothEntrypoints() throws Exception {
|
||||
var ds = new JdbcDataSource();
|
||||
ds.setURL("jdbc:h2:mem:calendar-port-display;MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
|
||||
sql.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR," +
|
||||
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_port_code VARCHAR,destination_warehouse_code VARCHAR," +
|
||||
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
|
||||
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
|
||||
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
|
||||
sql.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,actual_at TIMESTAMP)");
|
||||
sql.execute("CREATE TABLE fl_outbound_dispatch(container_booking_id BIGINT,confirmed_at TIMESTAMP,reversed_at TIMESTAMP,status VARCHAR)");
|
||||
sql.execute("INSERT INTO fl_container_booking(id,destination_port_name,destination_port_code,record_status) VALUES " +
|
||||
"(1,'洛杉矶','USLAX','ACTIVE'),(2,NULL,'USLAX','ACTIVE'),(3,' ','USLAX','ACTIVE')," +
|
||||
"(4,'洛杉矶',NULL,'ACTIVE'),(5,'',' ','ACTIVE')");
|
||||
sql.execute("INSERT INTO fl_outbound_dispatch SELECT id,'2026-10-01 00:00:00',NULL,'WAITING_FOR_LOADING' FROM fl_container_booking");
|
||||
}
|
||||
var config = new MybatisConfiguration();
|
||||
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
|
||||
config.addMapper(ContainerCalendarSourceMapper.class);
|
||||
try (var session = new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
|
||||
var mapper = session.getMapper(ContainerCalendarSourceMapper.class);
|
||||
var from = LocalDateTime.parse("2026-10-01T00:00:00");
|
||||
var calendar = mapper.sources(from, from.minusDays(2), from.minusDays(4), from.plusMonths(1));
|
||||
var explicit = mapper.byIds(java.util.List.of(1L, 2L, 3L, 4L, 5L));
|
||||
var expected = java.util.Arrays.asList("洛杉矶", "USLAX", "USLAX", "洛杉矶", null);
|
||||
assertEquals(expected, calendar.stream().map(row -> row.destinationPortName()).toList());
|
||||
for (var row : explicit) {
|
||||
assertEquals(expected.get(row.containerBookingId().intValue() - 1), row.destinationPortName());
|
||||
}
|
||||
assertEquals(5, explicit.size());
|
||||
}
|
||||
}
|
||||
|
||||
/** 后续观测可将实际到港修正为更早日期,两入口须取同一条最新实际记录。 */
|
||||
@Test
|
||||
void usesLatestObservedArrivalIncludingSameObservationIdTieBreakAcrossBothEntrypoints() throws Exception {
|
||||
@@ -18,7 +53,7 @@ class ContainerCalendarSourceMapperTest {
|
||||
ds.setURL("jdbc:h2:mem:arrival-source-correction;MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
|
||||
sql.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,cabinet_no VARCHAR,container_no VARCHAR," +
|
||||
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," +
|
||||
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_port_code VARCHAR,destination_warehouse_code VARCHAR," +
|
||||
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
|
||||
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
|
||||
sql.execute("CREATE TABLE fl_carrier_schedule_query(id BIGINT,version INT,status VARCHAR)");
|
||||
@@ -65,7 +100,7 @@ class ContainerCalendarSourceMapperTest {
|
||||
ds.setURL("jdbc:h2:mem:four-type-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
|
||||
sql.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR," +
|
||||
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," +
|
||||
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_port_code VARCHAR,destination_warehouse_code VARCHAR," +
|
||||
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
|
||||
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
|
||||
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
|
||||
| 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` 按国内冻结来源托盘整托收货。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 |
|
||||
| FBA收货短少确认 | `inboundplan/controller/InboundPlanController.java` 的 `/{planNo}/confirm-shortage` → `InboundPdaWorkflowService.confirmShortage`;仅平台入库编辑权限指定客户操作,要求待差异核对、原版本及应收/实收快照、原因和幂等键。仅同柜次同目的仓收货组单票且冻结申报单SKU可按总数确认,多SKU及跨票自动分摊拒绝;逐SKU实收不得超原申报,确认后保留应收与现场结束时间,按实收生成库存和原计租批次,计划/PDA任务进入 `PARTIAL_INBOUND`,尾程订单完成入库;同事务交接原预排,明确零实收的托盘同步移除预排明细、释放本车占用及预约分配并重算整车货量;缺少零实收证据或已有拣货拒绝。不重复发布卸柜事件。无实收、超收或逐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` |
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ public interface DispatchAggregateMapper {
|
||||
estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt},
|
||||
loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey},
|
||||
arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt},
|
||||
delivery_completed_at=#{r.deliveryCompletedAt},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount},
|
||||
delivery_completed_at=#{r.deliveryCompletedAt},item_count=#{r.itemCount},route=#{r.route},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount},
|
||||
shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt}
|
||||
WHERE id=#{r.id} AND record_version=#{version}
|
||||
""")
|
||||
|
||||
+4
-2
@@ -11,6 +11,8 @@ import java.util.List;
|
||||
public interface DispatchCandidateMapper {
|
||||
/** 自动预留托盘不是实收;尚未开始收货时仍以计划来源预排,不生成库存或实托装载事实。 */
|
||||
String PLAN_ONLY = "(p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0))";
|
||||
/** 完成入库或已确认短少后,不再把未实收的预留托盘当作在途货;真实库存仍可排车。 */
|
||||
String OPEN_PLAN = "COALESCE(ip.status,'') NOT IN ('COMPLETED','PARTIAL_INBOUND')";
|
||||
/** 在途计划的全部真实来源必须已离港;人工节点覆盖后不再借用原柜节点。 */
|
||||
String DEPARTED_PLAN =
|
||||
"EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " +
|
||||
@@ -178,7 +180,7 @@ public interface DispatchCandidateMapper {
|
||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
||||
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
|
||||
"AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " +
|
||||
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
|
||||
@@ -283,7 +285,7 @@ public interface DispatchCandidateMapper {
|
||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
||||
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
|
||||
"AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " +
|
||||
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
||||
|
||||
+19
-2
@@ -33,7 +33,7 @@ public interface DispatchSourceMapper {
|
||||
FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id
|
||||
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
|
||||
JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
|
||||
WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status='COMPLETED' AND p.warehouse_pallet_id IS NOT NULL
|
||||
WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND') AND p.warehouse_pallet_id IS NOT NULL
|
||||
GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address
|
||||
HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1
|
||||
AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1
|
||||
@@ -41,6 +41,23 @@ public interface DispatchSourceMapper {
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> confirmedPlan(@Param("customer") String customer,@Param("id") long id);
|
||||
|
||||
/** 只有原客户、原计划、原仓已结束且逐项明确为零的来源可移除;缺记录不代表短少。 */
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM ll_inbound_plan_pallet p
|
||||
JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id
|
||||
WHERE p.id=#{id} AND p.customer_id=#{customer} AND p.plan_id=#{plan}
|
||||
AND CAST(ip.overseas_warehouse_id AS CHAR(128))=#{warehouse}
|
||||
AND ip.status IN ('COMPLETED','PARTIAL_INBOUND')
|
||||
AND COALESCE(p.actual_package_count,0)=0
|
||||
AND EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id)
|
||||
AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id
|
||||
AND (a.counted_package_count IS NULL OR a.counted_package_count<>0 OR a.picked_package_count IS NULL OR a.picked_package_count<>0))
|
||||
AND NOT EXISTS (SELECT 1 FROM ll_inbound_stock_receipt r JOIN ll_inbound_plan_pallet_allocation a ON a.id=r.allocation_id AND a.customer_id=r.customer_id
|
||||
WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id)
|
||||
""")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
int confirmedEmptyPlan(@Param("customer") String customer,@Param("id") long id,@Param("plan") long plan,@Param("warehouse") String warehouse);
|
||||
|
||||
/** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */
|
||||
@Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p "
|
||||
+ "ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{dispatch} GROUP BY i.id")
|
||||
@@ -115,7 +132,7 @@ public interface DispatchSourceMapper {
|
||||
|
||||
/** 原计划完成实收后才能把预排估算替换为实际整托数量,避免收货中提前锁定半托。 */
|
||||
@Select("SELECT COUNT(*) FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id "
|
||||
+ "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PENDING_RECONCILIATION')")
|
||||
+ "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND','PENDING_RECONCILIATION')")
|
||||
int receivedPlanCompleted(@Param("customer") String customer,@Param("id") Long id);
|
||||
|
||||
/** 退回来源仍使用本票实际实收,不借普通入仓别名。 */
|
||||
|
||||
+2
@@ -68,6 +68,8 @@ public class DispatchSourceRepository {
|
||||
public List<DispatchPalletCandidate> selectedPlan(String customer, Long id) { return plans.readSelected(customer, List.of(id)); }
|
||||
/** 确认入库直接读取逐票实收,与可用、暂扣等出货资格分离。 */
|
||||
public List<DispatchPalletCandidate> confirmedPlan(String customer,long id) { return source.confirmedPlan(customer,id); }
|
||||
/** 空预排来源必须具备已完成的零实收事实,不能把来源缺失当作零。 */
|
||||
public boolean confirmedEmptyPlan(String customer,long id,long plan,String warehouse) { return source.confirmedEmptyPlan(customer,id,plan,warehouse)==1; }
|
||||
/** 取得已有真实退回候选。 */
|
||||
public List<DispatchPalletCandidate> selectedReturn(String customer, Long id) { return returns.readSelected(customer, List.of(id)); }
|
||||
/** 同托真实来源订单组成。 */
|
||||
|
||||
+19
@@ -733,6 +733,25 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
/** 自然实收只延续仍有效的原审核依据,失效审核继续保持失效。 */
|
||||
void saveReceivedCargo(DispatchAggregate row,String operator,LocalDateTime at,boolean preserveReview) {
|
||||
long version=row.getRecordVersion();
|
||||
// 空预排明细已移除后按剩余真实货物重算,避免列表、路线和车次继续保留幽灵托盘。
|
||||
var all=repository.cargo(row.getId());
|
||||
boolean removedItems=!Objects.equals(row.getItemCount(),all.size());
|
||||
int previousPackages=row.getPackageCount();
|
||||
row.setPackageCount(Math.toIntExact(all.stream().mapToLong(DispatchItem::getPackageCount).sum()));
|
||||
row.setItemCount(all.size());
|
||||
row.setPalletCount(Math.toIntExact(all.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count()));
|
||||
if(removedItems)row.setRoute(all.stream().map(this::destinationLabel).filter(Objects::nonNull).distinct().sorted().collect(Collectors.joining(",")));
|
||||
var digest=all.stream().sorted(Comparator.comparing(DispatchItem::getSourceKey)).map(i->List.of(i.getSourceKey(),i.getSourceVersion(),i.getCustomerId(),i.getPackageCount())).toList();
|
||||
row.setCargoHash(hashes.source(digest));row.setCargoRevision(Math.addExact(row.getCargoRevision(),1));
|
||||
if(removedItems || previousPackages!=row.getPackageCount()) {
|
||||
var stops=all.stream().map(i->new FleetExecutionModels.DispatchStop(destinationLabel(i),i.getDeliveryAddress(),destination(i).key()))
|
||||
.collect(Collectors.toMap(FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
|
||||
fleet.updateDispatchCargo(row.getDispatchNo(),row.getPackageCount(),stops);
|
||||
}
|
||||
if(all.isEmpty()) {
|
||||
invalidate(row,"实收核对后本车无货物,请重新安排货物并提交审核");
|
||||
row.setStage(Stage.BEFORE_LOADING.name());preserveReview=false;
|
||||
}
|
||||
if(preserveReview)row.setReviewBasisHash(basis(row));
|
||||
row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at);
|
||||
if(repository.saveState(row,version)!=1)throw conflict("收货交接期间排车版本已变化");
|
||||
|
||||
+22
-13
@@ -67,7 +67,13 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate
|
||||
if(!Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()))throw conflict("原排车已进入出货后阶段,无法确认实收交接");
|
||||
boolean preserveReview=workflow.currentReceiptBasis(row);
|
||||
var received=new LinkedHashMap<DispatchItem,DispatchPalletCandidate>();
|
||||
for(var item:pending) {
|
||||
var empty=new ArrayList<DispatchItem>();
|
||||
for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
|
||||
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
|
||||
if(sources.confirmedEmptyPlan(item.getCustomerId(),item.getPlanPalletId(),item.getPlanId(),row.getOverseasWarehouseId())) {
|
||||
empty.add(item);
|
||||
continue;
|
||||
}
|
||||
var actual=DispatchReceivedPlan.resolve(row.getOverseasWarehouseId(),item,sources)
|
||||
.orElseThrow(()->conflict("实收托盘与原排车来源或占用不一致,请核对后确认入库"));
|
||||
received.put(item,actual);
|
||||
@@ -79,14 +85,25 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate
|
||||
entry.getKey().setGrossWeightKg(entry.getValue().grossWeightKg());
|
||||
entry.getKey().setVolumeCbm(entry.getValue().volumeCbm());
|
||||
}
|
||||
for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
|
||||
for(var item:received.keySet().stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
|
||||
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
|
||||
var fresh=sources.confirmedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(item,fresh.get(0))
|
||||
|| !Objects.equals(item.getWarehousePalletId(),fresh.get(0).warehousePalletId())
|
||||
|| !Objects.equals(item.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请重新确认入库");
|
||||
}
|
||||
var pallets=pending.stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList();
|
||||
// 先释放空预排的逻辑占用;共享实托仍有成员时保留其物理排他。
|
||||
if(!empty.isEmpty()) {
|
||||
var owner=new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null);
|
||||
var original=physicalClaims.lockOwner(owner);
|
||||
for(var item:empty) {
|
||||
if(picking.picks(item.getCustomerId(),row.getId()).stream().anyMatch(p->Objects.equals(p.getDispatchItemId(),item.getId())))
|
||||
throw conflict("空托盘已有真实拣货,不能按短少移除");
|
||||
if(sources.release(row.getId(),item,at)!=1)throw conflict("空托盘原逻辑占用已变化");
|
||||
}
|
||||
physicalClaims.releaseIfUnused(owner,original.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock::getId).toList(),at);
|
||||
}
|
||||
var pallets=received.keySet().stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList();
|
||||
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null),pallets,at);
|
||||
for(var entry:received.entrySet()) {
|
||||
var item=entry.getKey();var actual=entry.getValue();
|
||||
@@ -95,16 +112,8 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate
|
||||
item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED");
|
||||
if(picking.materialize(item)!=1)throw conflict("原排车逐托实收数量未保存");
|
||||
}
|
||||
appointments.complete(row.getId(),pending.stream().map(i->new InboundDispatchReceiptGateway.Item(i.getId(),i.getCustomerId(),i.getPlanPalletId(),i.getPackageCount())).toList());
|
||||
int total=Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum());
|
||||
if(total!=row.getPackageCount()) {
|
||||
var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop(
|
||||
i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key()))
|
||||
.collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
|
||||
fleet.updateDispatchCargo(number,total,stops);
|
||||
}
|
||||
row.setPackageCount(total);
|
||||
row.setPalletCount(Math.toIntExact(items.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count()));
|
||||
appointments.complete(row.getId(),pending.stream().map(i->new InboundDispatchReceiptGateway.Item(i.getId(),i.getCustomerId(),i.getPlanPalletId(),empty.contains(i)?0:i.getPackageCount())).toList());
|
||||
for(var item:empty)if(repository.removeItem(item.getId())!=1)throw conflict("空托盘原排车明细已变化");
|
||||
workflow.saveReceivedCargo(row,operator,at,preserveReview);
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -38,6 +38,23 @@ public class InboundPlanController {
|
||||
@Resource private InboundPlanService service;
|
||||
@Resource private ContainerInboundPlanConversionCoordinator containerConversion;
|
||||
@Resource private InboundFbaAppointmentService appointmentService;
|
||||
@Resource private com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow;
|
||||
|
||||
/** 平台核对现场短少后按实收入库,不修改原应收数量。 */
|
||||
@PostMapping("/{planNo}/confirm-shortage")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')")
|
||||
@Operation(summary = "确认入库短少", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT)
|
||||
public ApiResponse<com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses.Task> confirmShortage(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest request) {
|
||||
if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可确认入库短少");
|
||||
}
|
||||
String customerId = convertScope(user, request.customerId());
|
||||
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
return ApiResponse.success(inboundWorkflow.confirmShortage(customerId, planNo, operatorId, idempotencyKey, request));
|
||||
}
|
||||
|
||||
/** 按入仓计划明细分页查询可预约的入库订单行。 */
|
||||
@GetMapping
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.lastleg.inboundplan.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/** 平台确认现场短少,数量仅用于核对当前收货事实,不能补写实收。 */
|
||||
public record ConfirmInboundShortageRequest(
|
||||
@NotBlank String customerId,
|
||||
@NotNull @Min(0) Integer version,
|
||||
@NotNull @Min(1) Integer expectedPackageCount,
|
||||
@NotNull @Min(1) Integer receivedPackageCount,
|
||||
@NotBlank @Size(max = 200) String reason
|
||||
) { }
|
||||
+1
-1
@@ -11,7 +11,7 @@ public interface InboundDispatchReceiptGateway {
|
||||
/** 库存与计划完成后,按实收逐托数量更新原运输安排,不扣出货库存。 */
|
||||
void complete(List<Long> planIds, String operatorId, LocalDateTime at);
|
||||
|
||||
/** 只传同一客户原计划的真实托盘与数量,不传推算的逐票分摊。 */
|
||||
/** 只传原计划真实数量;零表示已确认无实收,须释放该托的原预约分配。 */
|
||||
record Item(long itemId, String customerId, long planPalletId, int packages) { }
|
||||
|
||||
/** 预约域负责核对原批次归属及实收来源,排车域不直接修改预约账本。 */
|
||||
|
||||
+26
@@ -10,6 +10,32 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ReceivingSkuReceiptMapper extends BaseMapper<ReceivingSkuReceipt> {
|
||||
record FrozenSku(String skuCode, long packages) { }
|
||||
|
||||
/** 查询冻结申报的全部 SKU,不能从已自动分摊的实收反推缺失商品。 */
|
||||
@Select("SELECT UPPER(TRIM(d.sku_code)) AS skuCode,SUM(d.package_count) AS packages "
|
||||
+ "FROM ll_inbound_plan_line l JOIN ll_order_detail d ON d.order_id=l.order_id AND d.customer_id=l.customer_id "
|
||||
+ "WHERE l.customer_id=#{customerId} AND l.plan_id=#{planId} AND l.id=#{lineId} GROUP BY UPPER(TRIM(d.sku_code))")
|
||||
List<FrozenSku> frozenSkus(@Param("customerId") String customerId, @Param("planId") Long planId, @Param("lineId") Long lineId);
|
||||
|
||||
/** 同柜次同 FBA 仓按总箱数收货,跨客户只返回成员数用于拒绝有歧义的短少确认。 */
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM ll_inbound_plan_line selected
|
||||
JOIN ll_inbound_plan sp ON sp.id=selected.plan_id AND sp.customer_id=selected.customer_id
|
||||
JOIN ll_order so ON so.id=selected.order_id AND so.customer_id=selected.customer_id
|
||||
JOIN ll_inbound_plan p ON p.container_no=sp.container_no
|
||||
JOIN ll_inbound_plan_line l ON l.plan_id=p.id AND l.customer_id=p.customer_id
|
||||
JOIN ll_order o ON o.id=l.order_id AND o.customer_id=l.customer_id
|
||||
WHERE selected.customer_id=#{customerId} AND selected.plan_id=#{planId} AND selected.id=#{lineId}
|
||||
AND NULLIF(TRIM(so.source_container_booking_id),'') IS NOT NULL
|
||||
AND TRIM(o.source_container_booking_id)=TRIM(so.source_container_booking_id)
|
||||
AND TRIM(o.container_no)=sp.container_no
|
||||
AND COALESCE(o.order_type,'')<>'ONE_CLICK_DROPSHIP'
|
||||
AND UPPER(TRIM(l.fba_warehouse_code))=UPPER(TRIM(selected.fba_warehouse_code))
|
||||
AND UPPER(COALESCE(NULLIF(TRIM(l.destination_type),''),NULLIF(TRIM(o.destination_type),''),'FBA'))='FBA'
|
||||
""")
|
||||
long fbaGroupLineCount(@Param("customerId") String customerId, @Param("planId") Long planId, @Param("lineId") Long lineId);
|
||||
|
||||
@Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " +
|
||||
"AND line_id=#{lineId} AND sku_code=#{skuCode} ORDER BY id FOR UPDATE")
|
||||
List<ReceivingSkuReceipt> lockLineSku(@Param("customerId") String customerId,
|
||||
|
||||
+16
@@ -36,6 +36,22 @@ public class PdaWorkflowRepository {
|
||||
@Resource private InboundPalletMergeMapper merges;
|
||||
@Resource private InboundPlanCartonMapper cartons;
|
||||
@Resource private LastlegOrderMapper orders;
|
||||
@Resource private com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper skuReceipts;
|
||||
|
||||
/** 短少确认只能采用本客户本计划已登记的逐 SKU 实收。 */
|
||||
public List<com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt> skuReceipts(String customerId, Long planId) {
|
||||
return skuReceipts.listPlan(customerId, planId);
|
||||
}
|
||||
|
||||
/** 冻结申报用于判断总箱数能否唯一确定实际 SKU。 */
|
||||
public List<com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku> frozenSkus(String customerId, Long planId, Long lineId) {
|
||||
return skuReceipts.frozenSkus(customerId, planId, lineId);
|
||||
}
|
||||
|
||||
/** 返回整组来源数,不把其他客户的货物明细暴露给操作端。 */
|
||||
public long fbaGroupLineCount(String customerId, Long planId, Long lineId) {
|
||||
return skuReceipts.fbaGroupLineCount(customerId, planId, lineId);
|
||||
}
|
||||
|
||||
/** 收货完成前只读计划身份,运输父锁取得后再加来源行锁。 */
|
||||
public InboundPlan findPlan(String customerId, String planNo) {
|
||||
|
||||
+81
-1
@@ -716,7 +716,87 @@ public class InboundPdaWorkflowService {
|
||||
repository.update(plan);
|
||||
}
|
||||
|
||||
/** Explicit operator decision to receive only the cartons actually picked. */
|
||||
/** 平台确认 FBA 短少后将真实实收入库;原应收、现场结束时间和短少证据保留。 */
|
||||
@Transactional
|
||||
public PdaResponses.Task confirmShortage(String customerId, String planNo, String operatorId,
|
||||
String idempotencyKey, com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest request) {
|
||||
if (idempotencyKey == null || idempotencyKey.isBlank() || idempotencyKey.length() > 128
|
||||
|| operatorId == null || operatorId.isBlank() || request.reason() == null || request.reason().isBlank()) {
|
||||
throw conflict("短少确认必须提供操作人、请求键和确认原因");
|
||||
}
|
||||
InboundPlan original = repository.findPlan(customerId, planNo);
|
||||
if (original == null) throw notFound("入库单不存在或不属于当前客户");
|
||||
dispatchReceipts.lock(List.of(original.getId()));
|
||||
InboundPlan plan = requirePlan(customerId, planNo);
|
||||
// 将单号和操作人纳入幂等摘要,避免跨单或跨操作人复用请求键。
|
||||
Map<String, Object> command = Map.of("planNo", planNo, "operatorId", operatorId, "request", request);
|
||||
InboundPdaOperation replay = replay(customerId, "CONFIRM_SHORTAGE", idempotencyKey, command);
|
||||
if (replay != null) return read(replay, PdaResponses.Task.class);
|
||||
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
|
||||
if (!"PENDING_RECONCILIATION".equals(plan.getStatus()) || task == null
|
||||
|| !"PENDING_RECONCILIATION".equals(task.getStatus())) {
|
||||
throw conflict("仅待差异核对的入库单可确认短少,请刷新后重试");
|
||||
}
|
||||
if (!Objects.equals(plan.getVersion(), request.version())) throw conflict("入库单已变化,请刷新后重新确认短少");
|
||||
if (mode(plan) != InboundMode.FBA) throw conflict("当前短少确认仅适用于按 SKU 登记实收的 FBA 入库单");
|
||||
if (unloadPhotoFileIds(task).isEmpty() || task.getUnloadPhotoUploadedAt() == null || task.getCompletedAt() == null) {
|
||||
throw conflict("现场收货尚未结束或缺少卸柜凭证,不能确认短少");
|
||||
}
|
||||
List<InboundPlanLine> lines = repository.lines(plan.getId());
|
||||
List<InboundPlanPalletAllocation> allocations = repository.allocations(plan.getId());
|
||||
if (lines.isEmpty() || lines.stream().anyMatch(line -> !"FBA".equals(destinationType(line)))) {
|
||||
throw conflict("短少确认要求每条明细均为 FBA 实收货物");
|
||||
}
|
||||
int expected = lines.stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum();
|
||||
int received = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum();
|
||||
if (!Objects.equals(expected, request.expectedPackageCount()) || !Objects.equals(received, request.receivedPackageCount())) {
|
||||
throw conflict("应收或实收数量已变化,请刷新后重新确认");
|
||||
}
|
||||
if (received <= 0 || received >= expected) throw conflict("仅有实际收货且实收少于应收时可确认短少");
|
||||
var skuReceipts = repository.skuReceipts(customerId, plan.getId());
|
||||
// 批量收货的 SKU 行来自计划顺序分摊;只有单票单 SKU 的整组总数才是无歧义实收。
|
||||
if (lines.size() != 1) throw conflict("多票收货不能仅按总箱数确认短少,请先核实每票及每个 SKU 的实际数量");
|
||||
var onlyLine = lines.get(0);
|
||||
var frozenSkus = repository.frozenSkus(customerId, plan.getId(), onlyLine.getId());
|
||||
if (repository.fbaGroupLineCount(customerId, plan.getId(), onlyLine.getId()) != 1
|
||||
|| frozenSkus.size() != 1 || frozenSkus.get(0).skuCode() == null || frozenSkus.get(0).skuCode().isBlank()
|
||||
|| frozenSkus.get(0).packages() != expected) {
|
||||
throw conflict("当前仅支持同柜同目的仓单票、单 SKU 短少确认;多票或多 SKU 请先核实实际货物归属");
|
||||
}
|
||||
if (skuReceipts.stream().anyMatch(r -> r.getSkuCode() == null
|
||||
|| !frozenSkus.get(0).skuCode().equalsIgnoreCase(r.getSkuCode().trim()))) {
|
||||
throw conflict("实收 SKU 与冻结申报不一致,不能确认短少");
|
||||
}
|
||||
for (InboundPlanLine line : lines) {
|
||||
int picked = allocations.stream().filter(a -> Objects.equals(a.getLineId(), line.getId()))
|
||||
.mapToInt(a -> value(a.getPickedPackageCount())).sum();
|
||||
int skuPackages = skuReceipts.stream().filter(r -> Objects.equals(r.getLineId(), line.getId()))
|
||||
.mapToInt(r -> value(r.getReceivedPackages())).sum();
|
||||
if (picked < 0 || picked > value(line.getRequiredPackageCount()) || picked != skuPackages) {
|
||||
throw conflict("逐票 SKU 实收与托盘数量不一致或存在超收,不能按短少确认");
|
||||
}
|
||||
}
|
||||
if (allocations.stream().anyMatch(a -> value(a.getPickedPackageCount()) < 0
|
||||
|| value(a.getPickedPackageCount()) != value(a.getCountedPackageCount()))) {
|
||||
throw conflict("清点与收货数量不一致,不能确认短少");
|
||||
}
|
||||
List<InboundPlanPallet> pallets = new ArrayList<>(repository.pallets(plan.getId()));
|
||||
Set<Long> palletIds = pallets.stream().map(InboundPlanPallet::getId).collect(java.util.stream.Collectors.toSet());
|
||||
pallets.addAll(repository.palletsByIds(allocations.stream().map(InboundPlanPalletAllocation::getPalletId)
|
||||
.filter(id -> id != null && !palletIds.contains(id)).collect(java.util.stream.Collectors.toSet())));
|
||||
// 库存和库龄采用原现场收货时间,不因后台核对延迟重置;不重复发布柜级卸货事件。
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
stockReceipts.stockIn(customerId, plan, task, lines, allocations, pallets, task.getCompletedAt(), true);
|
||||
task.setStatus("PARTIAL_INBOUND"); task.setUpdatedAt(now); task.setVersion(value(task.getVersion()) + 1);
|
||||
repository.update(task);
|
||||
plan.setStatus("PARTIAL_INBOUND"); plan.setUpdatedAt(now); repository.update(plan);
|
||||
dispatchReceipts.complete(List.of(plan.getId()), operatorId, now);
|
||||
PdaResponses.Task result = taskResponse(task, plan, planNo);
|
||||
saveOperation(customerId, task.getId(), null, "CONFIRM_SHORTAGE", idempotencyKey, command, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 操作员明确选择仅将当前已收箱数入库。 */
|
||||
@Transactional
|
||||
public PdaResponses.Task partialComplete(String customerId, String planNo, PdaRequests.ForceComplete request) {
|
||||
InboundPlan plan = requirePlan(customerId, planNo);
|
||||
|
||||
+13
-4
@@ -48,6 +48,7 @@ public class InboundStockReceiptService {
|
||||
@Resource private DropshipOrderService dropshipOrders;
|
||||
@Resource private LastlegOrderStatusHistoryMapper orderHistories;
|
||||
|
||||
/** 按完整实收生成库存凭证。 */
|
||||
@Transactional
|
||||
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
|
||||
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
|
||||
@@ -55,6 +56,7 @@ public class InboundStockReceiptService {
|
||||
stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false);
|
||||
}
|
||||
|
||||
/** 部分入库仍按逐 SKU 实收生成凭证,原申报箱数仅作为上限。 */
|
||||
@Transactional
|
||||
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
|
||||
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
|
||||
@@ -103,7 +105,7 @@ public class InboundStockReceiptService {
|
||||
List<ReceivingSkuReceipt> exactSkuReceipts = skuReceiptsByLine.getOrDefault(line.getId(), List.of());
|
||||
if (!exactSkuReceipts.isEmpty()) {
|
||||
created.addAll(persistExactSkuReceipts(customerId, plan, task, line, lineAllocations,
|
||||
palletById, order, exactSkuReceipts, pickedPackages, receivedAt));
|
||||
palletById, order, exactSkuReceipts, pickedPackages, receivedAt, allowPartial));
|
||||
continue;
|
||||
}
|
||||
List<SkuRemainder> skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType())
|
||||
@@ -143,14 +145,17 @@ public class InboundStockReceiptService {
|
||||
completeOrders(customerId, plan, orderById, receivedAt, allowPartial);
|
||||
}
|
||||
|
||||
/** 逐 SKU 校验实收归属;短少可以少于冻结箱数,但不允许其他 SKU 超收抵扣。 */
|
||||
private List<InboundStockReceipt> persistExactSkuReceipts(String customerId, InboundPlan plan,
|
||||
InboundPdaTask task, InboundPlanLine line, List<InboundPlanPalletAllocation> lineAllocations,
|
||||
Map<Long, InboundPlanPallet> palletById, LastlegOrder order,
|
||||
List<ReceivingSkuReceipt> exactReceipts, long pickedPackages, LocalDateTime receivedAt) {
|
||||
List<ReceivingSkuReceipt> exactReceipts, long pickedPackages, LocalDateTime receivedAt, boolean allowPartial) {
|
||||
if (exactReceipts.stream().anyMatch(r -> r.getSkuCode() == null || r.getSkuCode().isBlank()
|
||||
|| value(r.getReceivedPackages()) <= 0)) throw conflict("SKU 实收必须包含有效 SKU 和正数箱数");
|
||||
long actualPackages = exactReceipts.stream().mapToLong(value -> value(value.getReceivedPackages())).sum();
|
||||
if (actualPackages != pickedPackages) throw conflict("FBA SKU receipts must equal the picked package total");
|
||||
|
||||
Map<String, Long> expectedBySku = skuRemainders(order.getId(), pickedPackages).stream()
|
||||
Map<String, Long> expectedBySku = skuRemainders(order.getId(), value(line.getRequiredPackageCount())).stream()
|
||||
.collect(Collectors.toMap(value -> value.skuCode, value -> value.remaining, Long::sum,
|
||||
java.util.LinkedHashMap::new));
|
||||
Map<String, Long> actualBySku = exactReceipts.stream().collect(Collectors.groupingBy(
|
||||
@@ -159,7 +164,11 @@ public class InboundStockReceiptService {
|
||||
Map<String, Long> normalizedExpected = expectedBySku.entrySet().stream().collect(Collectors.toMap(
|
||||
value -> value.getKey().toUpperCase(java.util.Locale.ROOT), Map.Entry::getValue, Long::sum,
|
||||
java.util.LinkedHashMap::new));
|
||||
if (!actualBySku.equals(normalizedExpected)) {
|
||||
boolean invalid = allowPartial
|
||||
? actualBySku.entrySet().stream().anyMatch(entry -> !normalizedExpected.containsKey(entry.getKey())
|
||||
|| entry.getValue() > normalizedExpected.get(entry.getKey()))
|
||||
: !actualBySku.equals(normalizedExpected);
|
||||
if (invalid) {
|
||||
throw conflict("FBA SKU receipts must exactly match the frozen SKU package snapshots");
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -738,7 +738,7 @@ public class ReceivingContainerService {
|
||||
boolean allPlansReady = totalOrders > 0 && inboundCodes == totalOrders
|
||||
&& plannedOrders == totalOrders && planCount == totalOrders;
|
||||
boolean allPlansCompleted = !matchedPlans.isEmpty()
|
||||
&& matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION")
|
||||
&& matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PARTIAL_INBOUND", "PENDING_RECONCILIATION")
|
||||
.contains(plan.getStatus()));
|
||||
boolean shippingReady = facts == null
|
||||
? shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1
|
||||
@@ -812,7 +812,7 @@ public class ReceivingContainerService {
|
||||
if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) {
|
||||
throw conflict("Every inbound plan in the receiving container must have an owning customer");
|
||||
}
|
||||
if (values.stream().allMatch(value -> List.of("COMPLETED", "PENDING_RECONCILIATION")
|
||||
if (values.stream().allMatch(value -> List.of("COMPLETED", "PARTIAL_INBOUND", "PENDING_RECONCILIATION")
|
||||
.contains(value.getStatus()))) {
|
||||
throw conflict("Receiving container is already completed");
|
||||
}
|
||||
|
||||
+8
@@ -37,6 +37,14 @@ public class AppointmentReceiptDispatchAdapter implements InboundDispatchReceipt
|
||||
for(var item:items) {
|
||||
var original=previous.stream().filter(r->r.itemId()==item.itemId()).toList();
|
||||
var physical=repository.physical(item.planPalletId(),item.customerId());
|
||||
// 零实收不再占用任何批次额度;明确核对数量和归属后仅移除本车本托分配。
|
||||
if(item.packages()==0) {
|
||||
if(physical.isEmpty() || physical.stream().anyMatch(p->p.countedPackages()==null || p.countedPackages()!=0)
|
||||
|| original.stream().anyMatch(r->!r.customerId().equals(item.customerId()) || r.palletId()!=item.planPalletId()))
|
||||
throw conflict("空托盘实收或原预约归属不一致,不能释放预约分配");
|
||||
binding.removeItem(dispatch,item.itemId());
|
||||
continue;
|
||||
}
|
||||
var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources);
|
||||
var quantities=new LinkedHashMap<List<String>,Integer>();
|
||||
for(var part:physical) {
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ public interface OrderWorkflowFactMapper {
|
||||
WHERE di.id=i.dispatch_item_id AND di.customer_id=i.customer_id AND di.dispatch_order_id=b.dispatch_order_id
|
||||
AND di.destination_type='PRIVATE' AND TRIM(di.delivery_address)=TRIM(d.delivery_address)
|
||||
AND COALESCE(TRIM(di.postal_code),'')=COALESCE(TRIM(d.postal_code),''))))) delivering,
|
||||
EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.customer_id=o.customer_id AND p.source_order_id=o.id AND p.status IN ('COMPLETED','PENDING_RECONCILIATION')) unloaded
|
||||
EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.customer_id=o.customer_id AND p.source_order_id=o.id AND p.status IN ('COMPLETED','PARTIAL_INBOUND','PENDING_RECONCILIATION')) unloaded
|
||||
FROM ll_order o WHERE o.source_region='FIRSTLEG' AND o.order_type NOT IN ('RELABEL_APPLICATION','WAREHOUSE_TRANSFER')
|
||||
AND <foreach collection='sources' item='s' open='(' separator=' OR ' close=')'>
|
||||
(o.customer_id=#{s.tenantKey} AND o.source_inbound_id=#{s.inboundKey})
|
||||
|
||||
@@ -667,6 +667,32 @@ paths:
|
||||
responses:
|
||||
'200': { description: 平台入库订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanDetailResponse' } } } }
|
||||
'404': { description: 入库订单不存在或不属于指定客户 }
|
||||
/api/platform/v1/last-leg/inbound-plans/{planNo}/confirm-shortage:
|
||||
post:
|
||||
summary: 确认FBA入库短少并按实收入库
|
||||
description: 权限 platform:overseas-warehouse:inbound-order:edit;仅平台指定客户操作。仅支持同柜次同目的仓收货组单票、冻结申报单SKU;多SKU或跨票合收不能按自动分摊确认。PENDING_RECONCILIATION且0<实收<应收时,核对版本及逐SKU实收,保留原应收和现场收货时间,按实收写入库存并进入PARTIAL_INBOUND,同事务交接原预排并移除明确零实收托盘的排车明细、占用和预约分配。操作原因与操作人写入收货操作记录;不重复发布卸柜事件。超收、SKU不一致、客户不匹配和陈旧版本拒绝;相同请求键重放不会重复入库。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: planNo, in: path, required: true, schema: { type: string } }
|
||||
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [customerId, version, expectedPackageCount, receivedPackageCount, reason]
|
||||
properties:
|
||||
customerId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
version: { type: integer, minimum: 0 }
|
||||
expectedPackageCount: { type: integer, minimum: 1 }
|
||||
receivedPackageCount: { type: integer, minimum: 1 }
|
||||
reason: { type: string, minLength: 1, maxLength: 200 }
|
||||
responses:
|
||||
'200': { description: ApiResponse包装的收货任务结果,status为PARTIAL_INBOUND }
|
||||
'403': { description: 非平台身份或无入库编辑权限 }
|
||||
'404': { description: 入库单不存在或客户不匹配 }
|
||||
'409': { description: 版本、状态、实收数量、SKU或幂等键冲突 }
|
||||
/api/platform/v1/last-leg/inbound-plans/manual:
|
||||
post:
|
||||
summary: 平台按单票手工生成入库计划
|
||||
|
||||
+30
-20
@@ -16,12 +16,12 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class DispatchCandidateMapperContractTest {
|
||||
/** 执行正式计划/实收判定与离港门禁,覆盖自动预留、人工覆盖、混合来源及库存耗尽。 */
|
||||
/** 执行正式候选和提交资格,覆盖在途预排、收货结束后的空预留托盘及真实库存。 */
|
||||
@Test
|
||||
void reservedPalletsCanBePlannedAfterDepartureWithoutInventingReceivedStock() throws Exception {
|
||||
try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-prearrival;MODE=MySQL")) {
|
||||
var db = connection.createStatement();
|
||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,status VARCHAR DEFAULT 'PDA_PENDING')");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR,actual_package_count INT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)");
|
||||
@@ -29,8 +29,8 @@ class DispatchCandidateMapperContractTest {
|
||||
db.execute("CREATE TABLE ll_order_manual_node(order_id BIGINT,customer_id VARCHAR,node_code VARCHAR,node_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id BIGINT,node_sequence INT,actual_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR,pallet_id BIGINT,available_packages INT)");
|
||||
for (int id = 1; id <= 9; id++) {
|
||||
db.execute("INSERT INTO ll_inbound_plan VALUES (" + id + ",'A')");
|
||||
for (int id = 1; id <= 14; id++) {
|
||||
db.execute("INSERT INTO ll_inbound_plan(id,customer_id) VALUES (" + id + ",'A')");
|
||||
db.execute("INSERT INTO ll_inbound_plan_pallet VALUES (" + id + "," + id + "," + (100 + id) + ",'RESERVED',0)");
|
||||
db.execute("INSERT INTO ll_inbound_plan_line VALUES (" + id + ",'A'," + id + ")");
|
||||
db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (" + id + "," + id + ")");
|
||||
@@ -44,24 +44,34 @@ class DispatchCandidateMapperContractTest {
|
||||
db.execute("INSERT INTO ll_order_manual_node VALUES (7,'A','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP),(9,'B','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP)");
|
||||
db.execute("INSERT INTO ll_inbound_plan_line VALUES (20,'A',2)");
|
||||
db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (8,20)");
|
||||
// 同一计划原分两托,最终21箱全收在一托;另一预留托盘不能继续冒充可预排货物。
|
||||
db.execute("UPDATE ll_inbound_plan SET status='COMPLETED' WHERE id IN (10,13,14)");
|
||||
db.execute("UPDATE ll_inbound_plan_pallet SET plan_id=10,assignment_status='ASSIGNED',actual_package_count=21 WHERE id=11");
|
||||
db.execute("INSERT INTO ll_inventory_position VALUES ('A',111,21)");
|
||||
db.execute("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND' WHERE id=12");
|
||||
db.execute("UPDATE ll_inbound_plan_pallet SET warehouse_pallet_id=NULL WHERE id=13");
|
||||
db.execute("UPDATE ll_inbound_plan_pallet SET assignment_status='ASSIGNED' WHERE id=14");
|
||||
String plan = DispatchCandidateMapper.PLAN_ONLY;
|
||||
String eligible = "AND (NOT " + plan + " OR (" + DispatchCandidateMapper.DEPARTED_PLAN + ")) "
|
||||
+ "AND (" + plan + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos "
|
||||
+ "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0))";
|
||||
var ids = new ArrayList<Long>();
|
||||
try (var rows = db.executeQuery("SELECT p.id,CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS physical,"
|
||||
+ "CASE WHEN " + plan + " THEN 'PLAN' ELSE 'RECEIVED' END AS stage "
|
||||
+ "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE 1=1 " + eligible + " ORDER BY p.id")) {
|
||||
while (rows.next()) {
|
||||
long id = rows.getLong("id"); ids.add(id);
|
||||
assertEquals(id == 3 ? "RECEIVED" : "PLAN", rows.getString("stage"));
|
||||
assertEquals(id == 3 ? 103L : null, rows.getObject("physical", Long.class));
|
||||
}
|
||||
}
|
||||
assertEquals(List.of(1L, 3L, 7L), ids);
|
||||
// 查询和写入选中来源共用同一判定;不允许仅在列表展示在途但提交时又要求实收。
|
||||
// 直接执行两条生产SQL中的资格片段,避免仅验证测试自行拼出的规则。
|
||||
for (String sql : List.of(DispatchCandidateMapper.AVAILABLE_PLAN_BODY, DispatchCandidateMapper.SELECTED_PLAN_SQL)) {
|
||||
assertTrue(sql.contains(eligible.replace("> 0", "> 0").replace("pos.customer_id=ip.customer_id", sql.contains("WHERE pos.customer_id=#{customerId}") ? "pos.customer_id=#{customerId}" : "pos.customer_id=ip.customer_id")));
|
||||
int start = sql.indexOf("AND (NOT " + plan + " OR (");
|
||||
int stockGate = sql.indexOf("AND (" + plan + " OR EXISTS", start);
|
||||
int end = sql.indexOf("AND NOT EXISTS (SELECT 1 FROM ll_", stockGate);
|
||||
String eligible = sql.substring(start, end).replace(">", ">")
|
||||
.replace("#{customerId}", "'A'");
|
||||
var ids = new ArrayList<Long>();
|
||||
try (var rows = db.executeQuery("SELECT p.id,p.actual_package_count,CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS physical,"
|
||||
+ "CASE WHEN " + plan + " THEN 'PLAN' ELSE 'RECEIVED' END AS stage "
|
||||
+ "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE 1=1 " + eligible + " ORDER BY p.id")) {
|
||||
while (rows.next()) {
|
||||
long id = rows.getLong("id"); ids.add(id);
|
||||
assertEquals(id == 3 || id == 11 ? "RECEIVED" : "PLAN", rows.getString("stage"));
|
||||
assertEquals(id == 3 || id == 11 ? 100L + id : null, rows.getObject("physical", Long.class));
|
||||
if (id == 11) assertEquals(21, rows.getInt("actual_package_count"));
|
||||
}
|
||||
}
|
||||
// 完成入库或已确认短少后,空预留托盘不可排车,真实实收托盘继续按库存校验。
|
||||
assertEquals(List.of(1L, 3L, 7L, 11L), ids);
|
||||
assertTrue(sql.contains("CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId"));
|
||||
}
|
||||
}
|
||||
|
||||
+78
-2
@@ -24,6 +24,74 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
/** 使用真实排车、预约绑定和MyBatis验证收货交接,再连续整托出货;库存外部命令沿原夹具隔离。 */
|
||||
class DispatchInboundReceiptPersistenceTest {
|
||||
/** 两托原预排21箱,短少后20+0:空托、预约和占用一起移除,20箱可继续整托拣货。 */
|
||||
@Test void shortageRemovesEmptyPreplannedPalletAndKeepsTwentyPickable() throws Exception {
|
||||
var f=new Fixture();
|
||||
f.p.f.db.update("UPDATE ll_dispatch_item SET package_count=11 WHERE plan_pallet_id=2");
|
||||
f.p.f.db.update("UPDATE ll_dispatch_order SET package_count=21");
|
||||
f.p.f.db.update("UPDATE ll_dispatch_appointment_allocation SET package_count=11 WHERE plan_pallet_id=2");
|
||||
f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET customer_id='101' WHERE plan_pallet_id=2");
|
||||
f.p.f.candidates.get(1L).packageCount(20);
|
||||
f.p.f.candidates.get(2L).packageCount(0);
|
||||
doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),eq(2L),anyLong(),eq("9"));
|
||||
f.p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash=?",(String)ReflectionTestUtils.invokeMethod(f.p.f.service,"basis",f.p.f.repository.find(f.p.number)));
|
||||
f.confirm(20,0);
|
||||
assertThat(f.quantities()).containsExactly(20);
|
||||
assertThat(f.allocations()).containsExactly(20);
|
||||
var row=f.p.f.repository.find(f.p.number);
|
||||
assertThat(row.getPackageCount()).isEqualTo(20);assertThat(row.getPalletCount()).isEqualTo(1);assertThat(row.getItemCount()).isEqualTo(1);
|
||||
assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_pallet_lock WHERE plan_pallet_id=2",String.class)).isEqualTo("RELEASED");
|
||||
assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_physical_pallet_lock WHERE warehouse_pallet_id=502",String.class)).isEqualTo("RELEASED");
|
||||
assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_physical_pallet_lock WHERE warehouse_pallet_id=501",String.class)).isEqualTo("ACTIVE");
|
||||
var version=f.version();f.confirm(20,0);assertThat(f.version()).isEqualTo(version);
|
||||
var picked=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.item,new DispatchCommands.Pick(version,"shortage-twenty",11L,20,0,null,null)));
|
||||
assertThat(((DispatchResults.Picked)picked.result()).pickingReconciled()).isTrue();
|
||||
verify(f.p.inventory).ship(eq("101"),eq("picker"),any());
|
||||
}
|
||||
|
||||
/** 原占用身份不匹配时,库存和预约也必须随空托移除失败一起回滚。 */
|
||||
@Test void failedEmptyPalletReleaseRollsBackStockAndDispatch() throws Exception {
|
||||
var f=new Fixture();f.p.f.candidates.get(1L).packageCount(20);
|
||||
doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),eq(2L),anyLong(),eq("9"));
|
||||
f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL WHERE plan_pallet_id=2");
|
||||
assertThatThrownBy(()->f.confirm(20,0)).hasMessageContaining("逻辑占用");
|
||||
assertThat(f.quantities()).containsExactly(10,10);assertThat(f.allocations()).containsExactly(10,10);
|
||||
assertThat(f.p.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero();
|
||||
}
|
||||
|
||||
/** 零候选但没有明确零实收证明,不能作为空托删除。 */
|
||||
@Test void missingEmptyReceiptEvidenceStillRejects() throws Exception {
|
||||
var f=new Fixture();f.p.f.candidates.get(1L).packageCount(20);f.p.f.candidates.get(2L).packageCount(0);
|
||||
assertThatThrownBy(()->f.confirm(20,0)).hasMessageContaining("实收托盘");
|
||||
assertThat(f.quantities()).containsExactly(10,10);assertThat(f.allocations()).containsExactly(10,10);
|
||||
}
|
||||
|
||||
/** 本车只预排了未收到的托盘时,收货后保留空车壳并清除货物与旧审核。 */
|
||||
@Test void dispatchWithOnlyEmptyPreplannedCargoReturnsToDraft() throws Exception {
|
||||
var f=new Fixture();
|
||||
f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET customer_id='101' WHERE plan_pallet_id=2");
|
||||
doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),anyLong(),anyLong(),eq("9"));
|
||||
f.confirm(0,0);
|
||||
assertThat(f.quantities()).isEmpty();assertThat(f.allocations()).isEmpty();
|
||||
var row=f.p.f.repository.find(f.p.number);
|
||||
assertThat(row.getPackageCount()).isZero();assertThat(row.getPalletCount()).isZero();assertThat(row.getItemCount()).isZero();
|
||||
assertThat(row.getRoute()).isEmpty();assertThat(row.getStage()).isEqualTo("BEFORE_LOADING");assertThat(row.getReviewState()).isEqualTo("DRAFT");
|
||||
assertThat(row.getReviewBasisHash()).isNull();
|
||||
}
|
||||
|
||||
/** 空托释放后若其他托盘预约交接失败,释放和库存仍须全部回滚。 */
|
||||
@Test void appointmentFailureRestoresReleasedEmptyClaim() throws Exception {
|
||||
var f=new Fixture();f.p.f.candidates.get(1L).packageCount(20);
|
||||
f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET customer_id='101' WHERE plan_pallet_id=2");
|
||||
doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),eq(2L),anyLong(),eq("9"));
|
||||
f.p.f.db.update("UPDATE ll_dispatch_appointment_allocation SET source_key='ORDER:wrong' WHERE plan_pallet_id=1");
|
||||
assertThatThrownBy(()->f.confirm(20,0)).hasMessageContaining("实收货物来源");
|
||||
assertThat(f.quantities()).containsExactly(10,10);
|
||||
assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_pallet_lock WHERE plan_pallet_id=2",String.class)).isEqualTo("ACTIVE");
|
||||
assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_physical_pallet_lock WHERE warehouse_pallet_id=502",String.class)).isEqualTo("ACTIVE");
|
||||
assertThat(f.p.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero();
|
||||
}
|
||||
|
||||
/** 直接读取9+11实收及对应货量,不读10+10计划估算,也不依赖可用库存或出货冻结状态。 */
|
||||
@Test void confirmedSourceMapperReadsReceiptCountsWithoutInventoryAvailability() throws Exception {
|
||||
var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:receipt-source-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
@@ -53,6 +121,8 @@ class DispatchInboundReceiptPersistenceTest {
|
||||
db.update("DELETE FROM ll_order_detail WHERE customer_id='101'");
|
||||
assertThat(mapper.confirmedPlan("101",1).get(0).grossWeightKg()).isNull();
|
||||
assertThat(mapper.confirmedPlan("other",1)).isEmpty();
|
||||
db.update("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND'");
|
||||
assertThat(mapper.confirmedPlan("101",1).get(0).packageCount()).isEqualTo(9);
|
||||
db.update("UPDATE ll_inbound_plan SET status='RECEIVING'");
|
||||
assertThat(mapper.confirmedPlan("101",1)).isEmpty();
|
||||
}
|
||||
@@ -199,6 +269,7 @@ class DispatchInboundReceiptPersistenceTest {
|
||||
doReturn(List.of(p.f.candidates.get(2L))).when(p.sources).selectedPlan("101",2L);
|
||||
doReturn(true).when(p.sources).receivedPlanCompleted("101",2L);
|
||||
for(long id=1;id<=2;id++) {
|
||||
doReturn(false).when(p.sources).confirmedEmptyPlan(eq("101"),eq(id),anyLong(),eq("9"));
|
||||
doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",null,id);
|
||||
doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",500+id,id);
|
||||
doReturn(id).when(p.sources).lockPlan("101",id);
|
||||
@@ -237,10 +308,15 @@ class DispatchInboundReceiptPersistenceTest {
|
||||
|
||||
/** 模拟收货库存落账后,在同一事务交接原排车和预约;不执行任何出库命令。 */
|
||||
void confirm() {
|
||||
confirm(9,11);
|
||||
}
|
||||
|
||||
/** 按指定真实数量验证短少和空托交接的同事务持久化。 */
|
||||
void confirm(int first,int second) {
|
||||
p.f.tx.executeWithoutResult(s->{
|
||||
receipt.lock(List.of(1L,2L));
|
||||
p.f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=CASE WHEN pallet_id=1 THEN 9 ELSE 11 END");
|
||||
p.f.db.update("UPDATE ll_inventory_position SET available_packages=CASE WHEN id=11 THEN 9 ELSE 11 END");
|
||||
p.f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=CASE WHEN pallet_id=1 THEN ? ELSE ? END",first,second);
|
||||
p.f.db.update("UPDATE ll_inventory_position SET available_packages=CASE WHEN id=11 THEN ? ELSE ? END",first,second);
|
||||
receipt.complete(List.of(1L,2L),"receiver",LocalDateTime.now(ZoneOffset.UTC));
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ class DispatchPlanPdaReadPersistenceTest {
|
||||
f.f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(2,1,'101')");
|
||||
var mapper=f.f.session.getMapper(com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.class);
|
||||
assertThat(mapper.receivedPlanCompleted("101",2L)).isZero();
|
||||
for(var status:List.of("COMPLETED","PENDING_RECONCILIATION")) {
|
||||
for(var status:List.of("COMPLETED","PARTIAL_INBOUND","PENDING_RECONCILIATION")) {
|
||||
f.f.db.update("UPDATE ll_inbound_plan SET status=?",status);
|
||||
assertThat(mapper.receivedPlanCompleted("101",2L)).isOne();
|
||||
assertThat(mapper.receivedPlanCompleted("other",2L)).isZero();
|
||||
|
||||
+13
@@ -31,6 +31,7 @@ class InboundPlanControllerTest {
|
||||
@Mock InboundPlanService service;
|
||||
@Mock InboundFbaAppointmentService appointmentService;
|
||||
@Mock ContainerInboundPlanConversionCoordinator containerConversion;
|
||||
@Mock com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow;
|
||||
private InboundPlanController controller;
|
||||
|
||||
@BeforeEach
|
||||
@@ -39,6 +40,18 @@ class InboundPlanControllerTest {
|
||||
ReflectionTestUtils.setField(controller, "service", service);
|
||||
ReflectionTestUtils.setField(controller, "appointmentService", appointmentService);
|
||||
ReflectionTestUtils.setField(controller, "containerConversion", containerConversion);
|
||||
ReflectionTestUtils.setField(controller, "inboundWorkflow", inboundWorkflow);
|
||||
}
|
||||
|
||||
/** 短少确认只允许平台指定具体客户,并保留真实操作人。 */
|
||||
@Test void platformConfirmsShortageWithExplicitCustomer() {
|
||||
var request = new com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest("100",4,21,20,"实收短少");
|
||||
controller.confirmShortage(platform(), "IP-1", "key", request);
|
||||
verify(inboundWorkflow).confirmShortage("100", "IP-1", "7", "key", request);
|
||||
var merchant = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of());
|
||||
assertThatThrownBy(() -> controller.confirmShortage(merchant, "IP-1", "key", request)).isInstanceOf(ResponseStatusException.class);
|
||||
var noCustomer = new com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest("0",4,21,20,"实收短少");
|
||||
assertThatThrownBy(() -> controller.confirmShortage(platform(), "IP-1", "key", noCustomer)).isInstanceOf(ResponseStatusException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.service;
|
||||
|
||||
import com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest;
|
||||
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.repository.PdaWorkflowRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
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.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InboundShortageConfirmationTest {
|
||||
@Mock PdaWorkflowRepository repository;
|
||||
@Mock InboundStockReceiptService stock;
|
||||
@Mock InboundDispatchReceiptGateway dispatch;
|
||||
@Mock InboundUnloadCompletedOutboxService outbox;
|
||||
InboundPdaWorkflowService service;
|
||||
InboundPlan plan;
|
||||
InboundPdaTask task;
|
||||
InboundPlanLine line;
|
||||
InboundPlanPalletAllocation allocation;
|
||||
final LocalDateTime receivedAt = LocalDateTime.of(2026, 10, 20, 12, 0);
|
||||
|
||||
/** 准备已结束现场收货、应收21实收20的冻结事实。 */
|
||||
@BeforeEach void setup() {
|
||||
service = new InboundPdaWorkflowService();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
ReflectionTestUtils.setField(service, "stockReceipts", stock);
|
||||
ReflectionTestUtils.setField(service, "dispatchReceipts", dispatch);
|
||||
ReflectionTestUtils.setField(service, "unloadCompletedOutbox", outbox);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
||||
plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setCustomerId("100");
|
||||
plan.setStatus("PENDING_RECONCILIATION"); plan.setVersion(4);
|
||||
task = new InboundPdaTask(); task.setId(2L); task.setPlanId(1L); task.setCustomerId("100");
|
||||
task.setStatus("PENDING_RECONCILIATION"); task.setVersion(2); task.setCompletedAt(receivedAt);
|
||||
task.setUnloadPhotoFileIds("[10]"); task.setUnloadPhotoUploadedAt(receivedAt);
|
||||
line = new InboundPlanLine(); line.setId(3L); line.setOrderId(9L); line.setFbaWarehouseCode("ONT8"); line.setRequiredPackageCount(21);
|
||||
allocation = new InboundPlanPalletAllocation(); allocation.setId(4L); allocation.setLineId(3L);
|
||||
allocation.setPalletId(5L); allocation.setPickedPackageCount(20); allocation.setCountedPackageCount(20);
|
||||
var sku = new ReceivingSkuReceipt(); sku.setLineId(3L); sku.setPalletId(5L); sku.setSkuCode("SKU-1"); sku.setReceivedPackages(20);
|
||||
when(repository.findPlan("100", "IP-1")).thenReturn(plan);
|
||||
lenient().when(repository.lockPlan("100", "IP-1")).thenReturn(plan);
|
||||
lenient().when(repository.lockTask("100", 1L)).thenReturn(task);
|
||||
lenient().when(repository.lines(1L)).thenReturn(List.of(line));
|
||||
lenient().when(repository.allocations(1L)).thenReturn(List.of(allocation));
|
||||
lenient().when(repository.skuReceipts("100", 1L)).thenReturn(List.of(sku));
|
||||
lenient().when(repository.frozenSkus("100", 1L, 3L)).thenReturn(List.of(
|
||||
new com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku("SKU-1",21)));
|
||||
lenient().when(repository.fbaGroupLineCount("100", 1L, 3L)).thenReturn(1L);
|
||||
}
|
||||
|
||||
/** 确认后保留原应收,按原收货时点入库并交接运输,重放不会重复记库存。 */
|
||||
@Test void stocksActualTwentyOnceAndPreservesShortageAndReceiptTime() {
|
||||
var request = request(4, 20);
|
||||
var result = service.confirmShortage("100", "IP-1", "admin", "key", request);
|
||||
assertEquals("PARTIAL_INBOUND", result.status()); assertEquals(21, line.getRequiredPackageCount());
|
||||
assertEquals(receivedAt, task.getCompletedAt());
|
||||
var sequence = inOrder(dispatch, repository, stock);
|
||||
sequence.verify(dispatch).lock(List.of(1L)); sequence.verify(repository).lockPlan("100", "IP-1");
|
||||
sequence.verify(stock).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(line)), eq(List.of(allocation)), anyList(), eq(receivedAt), eq(true));
|
||||
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("现场确认短少1箱"));
|
||||
when(repository.operation("100", "key")).thenReturn(audit.getValue());
|
||||
assertEquals(result, service.confirmShortage("100", "IP-1", "admin", "key", request));
|
||||
verify(stock, times(1)).stockIn(anyString(), any(), any(), anyList(), anyList(), anyList(), any(), eq(true));
|
||||
verifyNoInteractions(outbox);
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "new-key", request));
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 19)));
|
||||
}
|
||||
|
||||
/** 页面旧版本或旧实收数不能确认库存。 */
|
||||
@Test void rejectsStaleVersionAndCounts() {
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(3, 20)));
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 19)));
|
||||
verifyNoInteractions(stock);
|
||||
}
|
||||
|
||||
/** 没有逐 SKU 实收时不能按计划猜测缺少哪个商品。 */
|
||||
@Test void rejectsMissingSkuReceipts() {
|
||||
when(repository.skuReceipts("100", 1L)).thenReturn(List.of());
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20)));
|
||||
verifyNoInteractions(stock);
|
||||
}
|
||||
|
||||
/** 即使自动分摊后的实收只有一个 SKU,冻结申报中的另一 SKU 仍使短少归属不明确。 */
|
||||
@Test void rejectsMultipleFrozenSkusEvenWhenOnlyOneWasAllocated() {
|
||||
when(repository.frozenSkus("100",1L,3L)).thenReturn(List.of(
|
||||
new com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku("SKU-1",20),
|
||||
new com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku("SKU-2",1)));
|
||||
var error=assertThrows(ResponseStatusException.class,()->service.confirmShortage("100","IP-1","admin","key",request(4,20)));
|
||||
assertTrue(error.getReason().contains("单 SKU"));
|
||||
verifyNoInteractions(stock);verify(dispatch,never()).complete(anyList(),anyString(),any());
|
||||
assertEquals("PENDING_RECONCILIATION",plan.getStatus());
|
||||
}
|
||||
|
||||
/** 同柜同目的仓跨票合收时,即使每票单 SKU,也不能猜测短少属于哪个客户。 */
|
||||
@Test void rejectsSharedFbaReceivingGroup() {
|
||||
when(repository.fbaGroupLineCount("100",1L,3L)).thenReturn(2L);
|
||||
assertThrows(ResponseStatusException.class,()->service.confirmShortage("100","IP-1","admin","key",request(4,20)));
|
||||
verifyNoInteractions(stock);verify(repository,never()).insert(any(InboundPdaOperation.class));
|
||||
}
|
||||
|
||||
/** 客户归属不匹配时不触发任何库存或运输操作。 */
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(ints = {0, 21, 22})
|
||||
void rejectsZeroCompleteAndExcessReceipts(int received) {
|
||||
allocation.setPickedPackageCount(received); allocation.setCountedPackageCount(received);
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, received)));
|
||||
verifyNoInteractions(stock);
|
||||
}
|
||||
|
||||
/** FBA与逐箱入库混合时不能用本确认操作推断SKU归属。 */
|
||||
@Test void rejectsMixedDestinations() {
|
||||
var other = new InboundPlanLine(); other.setId(6L); other.setDestinationType("OVERSEAS");
|
||||
when(repository.lines(1L)).thenReturn(List.of(line, other));
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20)));
|
||||
verifyNoInteractions(stock);
|
||||
}
|
||||
|
||||
/** 库存落账失败时不推进计划状态和运输交接。 */
|
||||
@Test void doesNotAdvanceWhenStockInFails() {
|
||||
doThrow(new IllegalStateException("库存写入失败")).when(stock).stockIn(anyString(), any(), any(), anyList(), anyList(), anyList(), any(), eq(true));
|
||||
assertThrows(IllegalStateException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20)));
|
||||
assertEquals("PENDING_RECONCILIATION", plan.getStatus());
|
||||
verify(dispatch, never()).complete(anyList(), anyString(), any());
|
||||
verify(repository, never()).insert(any(InboundPdaOperation.class));
|
||||
}
|
||||
|
||||
/** 客户归属不匹配时不触发任何库存或运输操作。 */
|
||||
@Test void rejectsWrongCustomer() {
|
||||
when(repository.findPlan("100", "IP-1")).thenReturn(null);
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20)));
|
||||
verifyNoInteractions(stock, dispatch);
|
||||
}
|
||||
|
||||
/** 当前实收值随请求一起参与版本及幂等校验。 */
|
||||
private ConfirmInboundShortageRequest request(int version, int received) {
|
||||
return new ConfirmInboundShortageRequest("100", version, 21, received, "现场确认短少1箱");
|
||||
}
|
||||
}
|
||||
+28
@@ -324,6 +324,34 @@ class InboundStockReceiptServiceTest {
|
||||
List.of(new DropshipPalletSnapshot.Sku("SKU-1", "Product", packages, packages * 10)));
|
||||
}
|
||||
|
||||
/** FBA确认短少后只把20箱登记到库存,不修改21箱的冻结申报。 */
|
||||
@Test void exactSkuShortageStocksOnlyActualPackages() {
|
||||
Fixture f = fixture(21, 21, 20, 210);
|
||||
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
|
||||
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 20)));
|
||||
when(inventory.stockIn(eq("100"), eq("operator"), any()))
|
||||
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 20, 0, 0, 0, false, false));
|
||||
service.stockIn("100", f.plan, f.task, List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now(), true);
|
||||
var request = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
|
||||
verify(inventory).stockIn(eq("100"), eq("operator"), request.capture());
|
||||
assertEquals(20, request.getValue().packages());
|
||||
assertEquals(21, f.detail.getPackageCount());
|
||||
assertEquals("INBOUND_COMPLETED", f.order.getCurrentStatus());
|
||||
assertEquals("INBOUND_PLAN:PARTIAL_INBOUND", f.order.getSubOrderStatus());
|
||||
}
|
||||
|
||||
/** 一个SKU少收不能抵消另一个SKU超收,即使总箱数仍少于应收。 */
|
||||
@Test void partialSkuReceiptRejectsOverReceiptOfAnotherSku() {
|
||||
Fixture f = fixture(21, 21, 20, 210);
|
||||
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", 11), skuReceipt(12, 21, "SKU-2", 9)));
|
||||
assertThrows(ResponseStatusException.class, () -> service.stockIn("100", f.plan, f.task,
|
||||
List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now(), true));
|
||||
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");
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** 真实 Mapper 验证短少准入使用冻结申报、整组来源以及明确零实收,避免 Mock 掩盖取数错误。 */
|
||||
class ShortageReceiptEvidencePersistenceTest {
|
||||
/** 尚未分摊到实收的 SKU 仍须被识别,不能把 A20+B1 的短少误判为单 SKU。 */
|
||||
@Test void frozenSkusIncludeCompletelyMissingSkuAndRespectCustomer() throws Exception {
|
||||
var f=new Fixture();
|
||||
f.db.update("INSERT INTO ll_order_detail VALUES('101',11,'A',20),('101',11,'B',1),('other',11,'C',99)");
|
||||
assertThat(f.skus.frozenSkus("101",1L,10L)).containsExactlyInAnyOrder(
|
||||
new ReceivingSkuReceiptMapper.FrozenSku("A",20),new ReceivingSkuReceiptMapper.FrozenSku("B",1));
|
||||
assertThat(f.skus.frozenSkus("other",1L,10L)).isEmpty();
|
||||
f.db.update("UPDATE ll_order_detail SET sku_code=' a ' WHERE sku_code='B'");
|
||||
assertThat(f.skus.frozenSkus("101",1L,10L)).containsExactly(new ReceivingSkuReceiptMapper.FrozenSku("A",21));
|
||||
}
|
||||
|
||||
/** 同柜同目的仓另一客户也属于合收组;另一柜次、目的仓和一件代发不参与此分摊。 */
|
||||
@Test void countsCrossCustomerSourcesOnlyWithinSameBulkReceiptGroup() throws Exception {
|
||||
var f=new Fixture();assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1);
|
||||
f.db.update("INSERT INTO ll_inbound_plan VALUES(2,'202',9,'CONT','COMPLETED')");
|
||||
f.db.update("INSERT INTO ll_inbound_plan_line VALUES(20,2,'202',22,'FBA',' ont8 ')");
|
||||
f.db.update("INSERT INTO ll_order VALUES(22,'202','BOOK','CONT','FIRST_LEG','FBA')");
|
||||
assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(2);
|
||||
f.db.update("UPDATE ll_order SET source_container_booking_id='ANOTHER' WHERE id=22");
|
||||
assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1);
|
||||
f.db.update("UPDATE ll_order SET source_container_booking_id='BOOK',order_type='ONE_CLICK_DROPSHIP' WHERE id=22");
|
||||
assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1);
|
||||
f.db.update("UPDATE ll_order SET order_type='FIRST_LEG' WHERE id=22");
|
||||
f.db.update("UPDATE ll_inbound_plan_line SET fba_warehouse_code='LAX9' WHERE id=20");
|
||||
assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1);
|
||||
assertThat(f.skus.fbaGroupLineCount("202",1L,10L)).isZero();
|
||||
}
|
||||
|
||||
/** 缺记录、未结束、非零或已有库存凭据都不能释放预排,只有明确零实收可以。 */
|
||||
@Test void emptyPlanRequiresTerminalZeroEvidenceAndOriginalIdentity() throws Exception {
|
||||
var f=new Fixture();
|
||||
f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(100,1,'101',0)");
|
||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero();
|
||||
f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1000,100,'101',0,0)");
|
||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isEqualTo(1);
|
||||
assertThat(f.dispatch.confirmedEmptyPlan("other",100,1,"9")).isZero();
|
||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,2,"9")).isZero();
|
||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"10")).isZero();
|
||||
f.db.update("UPDATE ll_inbound_plan SET status='PENDING_RECONCILIATION'");
|
||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero();
|
||||
f.db.update("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND'");
|
||||
f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=NULL");
|
||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero();
|
||||
f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=0,picked_package_count=1");
|
||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero();
|
||||
f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET picked_package_count=0");
|
||||
f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(1000,'101')");
|
||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero();
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
final JdbcTemplate db;
|
||||
final ReceivingSkuReceiptMapper skus;
|
||||
final DispatchSourceMapper dispatch;
|
||||
|
||||
/** 每个测试使用独立内存库,只建立当前查询所需的真实字段。 */
|
||||
Fixture() throws Exception {
|
||||
var ds=new DriverManagerDataSource("jdbc:h2:mem:shortage-evidence-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
db=new JdbcTemplate(ds);
|
||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,overseas_warehouse_id BIGINT,container_no VARCHAR,status VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,customer_id VARCHAR,order_id BIGINT,destination_type VARCHAR,fba_warehouse_code VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_container_booking_id VARCHAR,container_no VARCHAR,order_type VARCHAR,destination_type VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR,order_id BIGINT,sku_code VARCHAR,package_count INT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR,actual_package_count INT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(id BIGINT,pallet_id BIGINT,customer_id VARCHAR,counted_package_count INT,picked_package_count INT)");
|
||||
db.execute("CREATE TABLE ll_inbound_stock_receipt(allocation_id BIGINT,customer_id VARCHAR)");
|
||||
db.update("INSERT INTO ll_inbound_plan VALUES(1,'101',9,'CONT','PARTIAL_INBOUND')");
|
||||
db.update("INSERT INTO ll_inbound_plan_line VALUES(10,1,'101',11,'FBA','ONT8')");
|
||||
db.update("INSERT INTO ll_order VALUES(11,'101','BOOK','CONT','FIRST_LEG','FBA')");
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
|
||||
config.addMapper(ReceivingSkuReceiptMapper.class);config.addMapper(DispatchSourceMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet();
|
||||
var session=new SqlSessionTemplate(factory.getObject());skus=session.getMapper(ReceivingSkuReceiptMapper.class);dispatch=session.getMapper(DispatchSourceMapper.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -33,6 +33,8 @@ class OrderWorkflowFactMapperTest {
|
||||
db.update("INSERT INTO ll_delivery_order VALUES(9,'11','DELIVERING',CURRENT_TIMESTAMP,'FBA1','3','FBA',NULL,NULL)");
|
||||
db.update("INSERT INTO ll_inbound_plan VALUES('11',1,'PENDING_RECONCILIATION')");
|
||||
assertThat(mapper.read(sources)).singleElement().satisfies(f->{assertThat(f.outboundExists()).isTrue();assertThat(f.pendingOutbound()).isFalse();assertThat(f.delivering()).isTrue();assertThat(f.unloaded()).isTrue();});
|
||||
db.update("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND'");
|
||||
assertThat(mapper.read(sources)).singleElement().satisfies(f->assertThat(f.unloaded()).isTrue());
|
||||
db.update("UPDATE ll_delivery_order SET destination_type='PRIVATE',destination_warehouse_code=NULL,destination_warehouse_id=NULL,delivery_address='Private A',postal_code='10001'");
|
||||
db.update("INSERT INTO ll_dispatch_item VALUES(60,'11',50,'PRIVATE','Private A','10001')");
|
||||
assertThat(mapper.read(sources)).singleElement().satisfies(f->assertThat(f.delivering()).isTrue());
|
||||
|
||||
Reference in New Issue
Block a user