feat: support shared FBA appointments and arrival calendar

This commit is contained in:
2026-10-09 11:01:39 +08:00
parent 4f9433c39a
commit e94271706c
34 changed files with 677 additions and 121 deletions
@@ -26,10 +26,12 @@ class InboundPlanSchemaContractTest {
assertTrue(schema.contains("create table if not exists ll_inbound_pda_operation"));
assertTrue(schema.contains("create table if not exists ll_inbound_pallet_merge"));
assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment"));
assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (customer_id, appointment_no)"));
assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (appointment_no)"));
assertTrue(schema.contains("expected_loading_at"));
assertTrue(schema.contains("change column tenant_id customer_id"));
assertTrue(schema.contains("set expected_loading_at=appointment_at"));
String appointment = schema.substring(schema.indexOf("create table if not exists ll_inbound_fba_appointment ("), schema.indexOf("create table if not exists ll_inbound_fba_appointment_item ("));
assertFalse(appointment.contains("customer_id"));
String items = schema.substring(schema.indexOf("create table if not exists ll_inbound_fba_appointment_item ("));
assertTrue(items.substring(0, items.indexOf("engine=innodb")).contains("customer_id varchar(128) not null"));
assertTrue(schema.contains("planned_arrival_date"));
assertTrue(schema.contains("creation_mode varchar(32)"));
assertTrue(schema.contains("modify column creation_mode varchar(32)"));
@@ -53,14 +53,14 @@ class InboundPlanControllerTest {
}
@Test
void platformAppointmentUpdateUsesExplicitCustomerScope() {
UpdateFbaAppointmentRequest request = new UpdateFbaAppointmentRequest("100",
void platformAppointmentUpdateUsesSharedAppointmentNumber() {
UpdateFbaAppointmentRequest request = new UpdateFbaAppointmentRequest(
LocalDateTime.of(2026, 8, 20, 10, 30), LocalDateTime.of(2026, 8, 21, 8, 0),
"PENDING", "dock");
"PENDING", "dock", null, 0);
controller.updateFbaAppointment(platform(), "APT-1", request);
verify(appointmentService).update("100", "APT-1", request);
verify(appointmentService).update("APT-1", request);
}
@Test
@@ -98,6 +98,17 @@ class InboundPlanControllerTest {
org.mockito.ArgumentMatchers.any());
}
@Test
void merchantCannotReadOrChangeACrossCustomerAppointment() {
var customer = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of());
assertThatThrownBy(() -> controller.fbaAppointmentDetail(customer, "APT-1"))
.isInstanceOf(ResponseStatusException.class);
assertThatThrownBy(() -> controller.fbaAppointmentCalendar(customer,
java.time.LocalDate.of(2026, 10, 1), java.time.LocalDate.of(2026, 11, 1)))
.isInstanceOf(ResponseStatusException.class);
verify(appointmentService, never()).detail(org.mockito.ArgumentMatchers.any());
}
private static AuthenticatedUser platform() {
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB",
Set.of("platform:overseas-warehouse:inbound-order:list"));
@@ -23,11 +23,15 @@ class InboundPlanPermissionContractTest {
Map<String, Method> methods = Arrays.stream(InboundPlanController.class.getDeclaredMethods())
.collect(Collectors.toMap(Method::getName, method -> method));
for (String name : new String[] {"page", "detail", "fbaAppointmentDetail"}) {
for (String name : new String[] {"page", "detail"}) {
Method method = methods.get(name);
assertEquals("hasAuthority('" + LIST + "')", method.getAnnotation(PreAuthorize.class).value());
assertEquals("Required authority: " + LIST, method.getAnnotation(Operation.class).description());
}
assertEquals("hasAnyAuthority('" + LIST + "','platform:appointment:list')",
methods.get("fbaAppointmentDetail").getAnnotation(PreAuthorize.class).value());
assertEquals("hasAuthority('platform:appointment:list')",
methods.get("fbaAppointmentCalendar").getAnnotation(PreAuthorize.class).value());
}
@Test
@@ -25,8 +25,8 @@ class InboundPlanDispatchStatusPersistenceTest {
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,plan_no VARCHAR(50),customer_id VARCHAR(20),overseas_warehouse_id BIGINT,assigned_flag BOOLEAN,merge_required BOOLEAN,urgent_flag BOOLEAN,status VARCHAR(30),created_at TIMESTAMP,version INT)");
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,order_id BIGINT,inbound_code VARCHAR(50),destination_warehouse_id VARCHAR(30),destination_warehouse_code VARCHAR(30),destination_warehouse_name VARCHAR(50),fba_warehouse_code VARCHAR(30),required_package_count INT,volume_cbm DECIMAL(18,3),planned_pallet_count INT)");
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(20),lx_code VARCHAR(50),order_type VARCHAR(30),overseas_warehouse_code VARCHAR(30),overseas_warehouse_name VARCHAR(50),customer_name VARCHAR(50))");
db.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT,line_id BIGINT,appointment_id BIGINT)");
db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,customer_id VARCHAR(20),created_at TIMESTAMP,appointment_no VARCHAR(50),appointment_status VARCHAR(30),appointment_at TIMESTAMP,expected_loading_at TIMESTAMP)");
db.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT,line_id BIGINT,appointment_id BIGINT,customer_id VARCHAR(20))");
db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,created_at TIMESTAMP,appointment_no VARCHAR(50),appointment_status VARCHAR(30),appointment_at TIMESTAMP,expected_loading_at TIMESTAMP)");
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(line_id BIGINT,picked_package_count INT)");
db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))");
db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))");
@@ -78,6 +78,23 @@ class InboundPlanDispatchStatusPersistenceTest {
assertThat(mapper.count("B", query(null))).isZero();
}
@Test
void sharedAppointmentAppearsForBothCustomersWithoutExposingOtherCustomersInboundRows() {
db.update("INSERT INTO ll_inbound_plan VALUES(4,'LIP-2','B',90,TRUE,FALSE,FALSE,'RECEIVED',CURRENT_TIMESTAMP,0)");
db.update("INSERT INTO ll_inbound_plan_line VALUES(5,4,6,'IN-2','91','ABE4','ABE4','ABE4',1,1,1)");
db.update("INSERT INTO ll_order VALUES(6,'B','LX-2','FIRST_LEG','LA','洛杉矶仓','客户B')");
db.update("INSERT INTO ll_inbound_fba_appointment VALUES(7,CURRENT_TIMESTAMP,'APT-SHARED','SUCCESS',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)");
db.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(8,2,7,'A'),(9,5,7,'B')");
assertThat(mapper.rows(null, query(null), 0, 20)).hasSize(2)
.extracting(InboundPlanListResponse::appointmentNo).containsOnly("APT-SHARED");
for (String customer : new String[]{"A", "B"}) {
var rows = mapper.rows(customer, query(null), 0, 20);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).customerId()).isEqualTo(customer);
assertThat(rows.get(0).appointmentNo()).isEqualTo("APT-SHARED");
}
}
private void dispatch(long id, String owner, String status, String itemOwner) {
db.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, owner, status);
db.update("INSERT INTO ll_dispatch_item VALUES(?,?,?,3)", id, itemOwner, id);
@@ -19,6 +19,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -46,49 +47,54 @@ class InboundFbaAppointmentServiceTest {
ReflectionTestUtils.setField(service, "itemMapper", itemMapper);
ReflectionTestUtils.setField(service, "queryMapper", queryMapper);
lenient().when(itemMapper.selectList(any())).thenReturn(List.of());
lenient().when(appointmentMapper.update(any(InboundFbaAppointment.class), any())).thenReturn(1);
}
@Test
void createsOneBatchForSelectedLinesAndFreezesFbaWarehouse() throws Exception {
assertEquals(true, InboundFbaAppointmentService.class
.getMethod("batchCreate", String.class, String.class, BatchFbaAppointmentRequest.class)
.getMethod("batchCreate", String.class, BatchFbaAppointmentRequest.class)
.getAnnotation(Transactional.class) != null);
when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(
when(queryMapper.lockLines(List.of(11L, 12L))).thenReturn(List.of(
line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "ONT8")));
var result = service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1"));
var result = service.batchCreate("7", request(List.of(11L, 12L), "APT-1"));
assertEquals("APT-1", result.appointmentNo());
assertEquals("ONT8", result.fbaWarehouseCode());
assertEquals(expectedLoadingAt, result.expectedLoadingAt());
assertEquals(2, result.selectedCount());
var captured = org.mockito.ArgumentCaptor.forClass(InboundFbaAppointmentItem.class);
verify(itemMapper, org.mockito.Mockito.times(2)).insert(captured.capture());
assertEquals(List.of("CUSTOMER-11", "CUSTOMER-12"), captured.getAllValues().stream()
.map(InboundFbaAppointmentItem::getCustomerId).toList());
verify(appointmentMapper).insert(any(InboundFbaAppointment.class));
verify(itemMapper, org.mockito.Mockito.times(2)).insert(any(InboundFbaAppointmentItem.class));
}
@Test
void rejectsMixedFbaWarehouses() {
when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(
when(queryMapper.lockLines(List.of(11L, 12L))).thenReturn(List.of(
line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "LAX9")));
assertThrows(ResponseStatusException.class,
() -> service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1")));
() -> service.batchCreate("7", request(List.of(11L, 12L), "APT-1")));
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
}
@Test
void rejectsMissingLineFromAnotherTenant() {
when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(line(11L, "IN-11", "ONT8")));
void rejectsMissingSelectedLine() {
when(queryMapper.lockLines(List.of(11L, 12L))).thenReturn(List.of(line(11L, "IN-11", "ONT8")));
assertThrows(ResponseStatusException.class,
() -> service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1")));
() -> service.batchCreate("7", request(List.of(11L, 12L), "APT-1")));
}
@Test
void duplicateLineIdsAreRejectedBeforeDatabaseAccess() {
assertThrows(ResponseStatusException.class,
() -> service.batchCreate("100", "7", request(List.of(11L, 11L), "APT-1")));
verify(queryMapper, never()).lockLines(any(), any());
() -> service.batchCreate("7", request(List.of(11L, 11L), "APT-1")));
verify(queryMapper, never()).lockLines(any());
}
@Test
@@ -101,7 +107,7 @@ class InboundFbaAppointmentServiceTest {
when(appointmentMapper.selectOne(any())).thenReturn(existing);
when(itemMapper.selectList(any())).thenReturn(List.of(item));
var result = service.batchCreate("100", "7", request(List.of(11L), "APT-1"));
var result = service.batchCreate("7", request(List.of(11L), "APT-1"));
assertEquals(99L, result.id());
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
@@ -115,7 +121,7 @@ class InboundFbaAppointmentServiceTest {
when(itemMapper.selectList(any())).thenReturn(List.of(item));
assertThrows(ResponseStatusException.class,
() -> service.batchCreate("100", "7", request(List.of(12L), "APT-1")));
() -> service.batchCreate("7", request(List.of(12L), "APT-1")));
}
@Test
@@ -124,17 +130,17 @@ class InboundFbaAppointmentServiceTest {
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
item.setLineId(11L);
when(itemMapper.selectList(any())).thenReturn(List.of(item));
BatchFbaAppointmentRequest changed = new BatchFbaAppointmentRequest("100", List.of(11L), "APT-1",
appointmentAt, expectedLoadingAt.plusHours(1), "SUCCESS", "dock");
BatchFbaAppointmentRequest changed = new BatchFbaAppointmentRequest(List.of(11L), "APT-1",
appointmentAt, expectedLoadingAt.plusHours(1), "SUCCESS", "dock", null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.batchCreate("100", "7", changed));
() -> service.batchCreate("7", changed));
assertEquals(409, exception.getStatusCode().value());
}
@Test
void stringSnowflakeLineIdsDeserializeWithoutPrecisionLoss() throws Exception {
String json = "{\"customerId\":\"100\",\"lineIds\":[\"9007199254740993\"],"
String json = "{\"lineIds\":[\"9007199254740993\"],"
+ "\"appointmentNo\":\"APT-1\",\"appointmentAt\":\"2026-08-20T10:30:00\","
+ "\"expectedLoadingAt\":\"2026-08-21T08:00:00\",\"appointmentStatus\":\"PENDING\"}";
@@ -151,28 +157,122 @@ class InboundFbaAppointmentServiceTest {
when(appointmentMapper.selectOne(any())).thenReturn(existing);
LocalDateTime loadingAt = expectedLoadingAt.plusHours(2);
var result = service.update("100", "APT-1", new UpdateFbaAppointmentRequest(
"100", appointmentAt.plusDays(1), loadingAt, "PENDING", "updated"));
var result = service.update("APT-1", new UpdateFbaAppointmentRequest(
appointmentAt.plusDays(1), loadingAt, "PENDING", "updated", null, 0));
assertEquals(loadingAt, result.expectedLoadingAt());
assertEquals("PENDING", result.appointmentStatus());
verify(appointmentMapper).updateById(existing);
verify(appointmentMapper).update(org.mockito.ArgumentMatchers.eq(existing), any());
verify(itemMapper, never()).insert(any(InboundFbaAppointmentItem.class));
}
@Test
void preventsAssigningTheSameCargoToTwoActiveAppointments() {
when(queryMapper.lockLines(List.of(11L))).thenReturn(List.of(line(11L, "IN-11", "ONT8")));
when(queryMapper.countActiveAppointments(List.of(11L), null)).thenReturn(1L);
var exception = assertThrows(ResponseStatusException.class,
() -> service.batchCreate("7", request(List.of(11L), "SECOND-APT")));
assertEquals(409, exception.getStatusCode().value());
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
}
@Test
void cancelledAppointmentCannotBeReactivatedAfterItsCargoWasBookedElsewhere() {
InboundFbaAppointment existing = appointment(99L, "APT-1", "ONT8");
existing.setAppointmentStatus("CANCELLED");
when(appointmentMapper.selectOne(any())).thenReturn(existing);
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
item.setLineId(11L);
when(itemMapper.selectList(any())).thenReturn(List.of(item));
when(queryMapper.countActiveAppointments(List.of(11L), 99L)).thenReturn(1L);
assertThrows(ResponseStatusException.class, () -> service.update("APT-1",
new UpdateFbaAppointmentRequest(appointmentAt, expectedLoadingAt, "SUCCESS", "", null, 0)));
verify(queryMapper).lockLines(List.of(11L));
verify(appointmentMapper, never()).update(any(InboundFbaAppointment.class), any());
}
@Test
void rejectsAnAppointmentWindowWhoseEndIsNotAfterItsStart() {
var request = new BatchFbaAppointmentRequest(List.of(11L), "BAD-WINDOW", appointmentAt,
expectedLoadingAt, "PENDING", "", appointmentAt.minusMinutes(1));
assertThrows(ResponseStatusException.class, () -> service.batchCreate("7", request));
verify(queryMapper, never()).lockLines(any());
}
@Test
void rejectsStaleEditBeforeWritingTheSharedAppointment() {
var existing = appointment(99L, "APT-1", "ONT8");
existing.setVersion(3);
when(appointmentMapper.selectOne(any())).thenReturn(existing);
var request = new UpdateFbaAppointmentRequest(appointmentAt, expectedLoadingAt,
"PENDING", "", appointmentAt.plusHours(2), 2);
var error = assertThrows(ResponseStatusException.class, () -> service.update("APT-1", request));
assertEquals(409, error.getStatusCode().value());
verify(appointmentMapper, never()).update(any(InboundFbaAppointment.class), any());
}
@Test
void cannotDeleteAnAppointmentWhoseGoodsAlreadyHaveAnActiveDispatch() {
when(appointmentMapper.selectOne(any())).thenReturn(appointment(99L, "APT-1", "ONT8"));
when(queryMapper.countActiveDispatches(99L)).thenReturn(1L);
assertThrows(ResponseStatusException.class, () -> service.delete("APT-1", 0));
verify(appointmentMapper, never()).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
verify(itemMapper, never()).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
}
@Test
void staleDeleteKeepsAllCargoAssociations() {
when(appointmentMapper.selectOne(any())).thenReturn(appointment(99L, "APT-1", "ONT8"));
when(appointmentMapper.delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(0);
assertThrows(ResponseStatusException.class, () -> service.delete("APT-1", 0));
verify(itemMapper, never()).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
}
private BatchFbaAppointmentRequest request(List<Long> lineIds, String number) {
return new BatchFbaAppointmentRequest("100", lineIds, number, appointmentAt, expectedLoadingAt,
"SUCCESS", "dock");
return new BatchFbaAppointmentRequest(lineIds, number, appointmentAt, expectedLoadingAt,
"SUCCESS", "dock", null);
}
@Test
void calendarRejectsInvalidOrUnboundedDateRangesBeforeQuerying() {
LocalDate from = LocalDate.of(2026, 9, 1);
for (LocalDate to : List.of(from, from.minusDays(1), from.plusDays(43))) {
assertThrows(ResponseStatusException.class, () -> service.calendar(from, to));
}
verify(appointmentMapper, never()).selectList(any());
}
@Test
void emptyCalendarDoesNotQueryAppointmentItems() {
when(appointmentMapper.selectList(any())).thenReturn(List.of());
assertEquals(List.of(), service.calendar(LocalDate.of(2026, 9, 1), LocalDate.of(2026, 10, 1)));
verify(itemMapper, never()).selectList(any());
}
@Test
void calendarLoadsAllItemsInOneBatchAndKeepsAppointmentAssociation() {
var first = appointment(99L, "APT-1", "ONT8");
var second = appointment(100L, "APT-2", "LAX9");
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
item.setAppointmentId(100L);
item.setLineId(11L);
item.setInboundCode("IN-11");
when(appointmentMapper.selectList(any())).thenReturn(List.of(first, second));
when(itemMapper.selectList(any())).thenReturn(List.of(item));
var result = service.calendar(LocalDate.of(2026, 9, 1), LocalDate.of(2026, 10, 1));
assertEquals(2, result.size());
assertEquals(0, result.get(0).items().size());
assertEquals("IN-11", result.get(1).items().get(0).inboundCode());
verify(itemMapper).selectList(any());
}
private FbaAppointmentLineSnapshot line(Long id, String inboundCode, String fba) {
return new FbaAppointmentLineSnapshot(id, 1000L, id + 100L, inboundCode, fba);
return new FbaAppointmentLineSnapshot(id, 1000L, id + 100L, inboundCode, fba, "CUSTOMER-" + id);
}
private InboundFbaAppointment appointment(Long id, String number, String fba) {
InboundFbaAppointment value = new InboundFbaAppointment();
value.setId(id);
value.setCustomerId("100");
value.setAppointmentNo(number);
value.setAppointmentAt(appointmentAt);
value.setExpectedLoadingAt(expectedLoadingAt);
@@ -180,6 +280,7 @@ class InboundFbaAppointmentServiceTest {
value.setAppointmentRemark("dock");
value.setFbaWarehouseCode(fba);
value.setSelectedCount(1);
value.setVersion(0);
return value;
}
}