feat(appointments): add four-type calendar sources and align release contracts

This commit is contained in:
2026-10-19 12:15:43 +08:00
parent fe1b2a6a46
commit aa195cef66
21 changed files with 599 additions and 65 deletions
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.calendar;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/last-leg/appointment-calendar")
public class WarehouseCalendarController {
private final WarehouseCalendarService service;
/** 装配整柜到仓日历读取服务。 */
public WarehouseCalendarController(WarehouseCalendarService service) { this.service = service; }
/** 使用预约和原订单读取权限,写资格继续使用原订单编辑权限。 */
@PostMapping("/warehouse-arrivals/query")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
public ApiResponse<List<WarehouseCalendarModels.Entry>> calendar(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody WarehouseCalendarModels.Query query) {
if (user == null || !"PLATFORM".equals(user.appCode()) || user.tenantId() == null || user.tenantId() != 0L)
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台可读预约日历");
return ApiResponse.success(service.calendar(query, user.permissions().contains(OverseasPermissions.LAST_MILE_ORDER_EDIT)));
}
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.calendar;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface WarehouseCalendarMapper {
/** 同柜全部客户合并为一条;人工时间和已建入库计划的时间不能被到港推算覆盖。 */
@Select("""
<script>
SELECT * FROM (
SELECT o.source_container_booking_id sourceContainerBookingId,MAX(o.container_no) containerNo,
CASE WHEN COUNT(DISTINCT o.overseas_warehouse_id)=1 THEN MAX(o.overseas_warehouse_name) END warehouseName,
CASE WHEN COUNT(DISTINCT o.overseas_warehouse_id)=1 THEN MAX(o.overseas_warehouse_code) END warehouseCode,
CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) AND COUNT(DISTINCT o.estimated_warehouse_at)=1
AND (MAX(o.arrival_time_version)>0 OR EXISTS(SELECT 1 FROM ll_inbound_plan p
JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id
WHERE po.source_container_booking_id=o.source_container_booking_id))
THEN MAX(o.estimated_warehouse_at) END plannedAt,
(SELECT MIN(s.receiving_at) FROM ll_receiving_container_session s
WHERE s.source_container_booking_id=o.source_container_booking_id) actualAt,
CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END version,
(SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id
WHERE po.source_container_booking_id=o.source_container_booking_id) planCount,
FALSE canEdit
FROM ll_order o WHERE o.source_container_booking_id IS NOT NULL
GROUP BY o.source_container_booking_id
) c WHERE
(c.plannedAt &gt;= #{from} AND c.plannedAt &lt; #{to})
<if test="ids != null and !ids.isEmpty()">
OR c.sourceContainerBookingId IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</if>
ORDER BY c.sourceContainerBookingId LIMIT 2001
</script>
""")
List<WarehouseCalendarModels.Entry> calendar(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to, @Param("ids") List<String> ids);
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.calendar;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 只读日历沿用整柜预计到仓版本,不保存独立计划台账。 */
public final class WarehouseCalendarModels {
/** 禁止构造工具类。 */
private WarehouseCalendarModels() { }
public record Query(@NotNull LocalDate from, @NotNull LocalDate to,
@NotNull @Size(max = 2000) List<@NotBlank @Pattern(regexp = "[1-9][0-9]*") String> sourceContainerBookingIds) { }
public record Entry(String sourceContainerBookingId, String containerNo, String warehouseName,
String warehouseCode, LocalDateTime plannedAt, LocalDateTime actualAt, int version,
long planCount, boolean canEdit) { }
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.calendar;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class WarehouseCalendarRepository {
private final WarehouseCalendarMapper mapper;
/** 只通过本服务 Mapper 查询柜级到仓事实。 */
public WarehouseCalendarRepository(WarehouseCalendarMapper mapper) { this.mapper = mapper; }
/** 同时覆盖跨月人工改期的柜与本次到港来源柜。 */
public List<WarehouseCalendarModels.Entry> calendar(WarehouseCalendarModels.Query query) {
return mapper.calendar(query.from().atStartOfDay(), query.to().atStartOfDay(), query.sourceContainerBookingIds());
}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.calendar;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.temporal.ChronoUnit;
import java.util.HashSet;
import java.util.List;
@Service
public class WarehouseCalendarService {
private final WarehouseCalendarRepository repository;
/** 装配日历只读仓储查询。 */
public WarehouseCalendarService(WarehouseCalendarRepository repository) { this.repository = repository; }
/** 明确权限、版本与计划冻结资格;不以默认计划改写真实到仓时间。 */
public List<WarehouseCalendarModels.Entry> calendar(WarehouseCalendarModels.Query query, boolean canEdit) {
if (query.from() == null || query.to() == null || !query.to().isAfter(query.from())
|| ChronoUnit.DAYS.between(query.from(), query.to()) > 42
|| new HashSet<>(query.sourceContainerBookingIds()).size() != query.sourceContainerBookingIds().size())
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历范围须为1至42天,来源柜不能重复");
var rows = repository.calendar(query);
if (rows.size() > 2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "到仓记录过多,请缩小日期范围");
return rows.stream().map(row -> new WarehouseCalendarModels.Entry(row.sourceContainerBookingId(), row.containerNo(),
row.warehouseName(), row.warehouseCode(), row.plannedAt(), row.actualAt(), row.version(), row.planCount(),
canEdit && row.version() >= 0 && row.planCount() == 0 && row.actualAt() == null)).toList();
}
}
@@ -0,0 +1,56 @@
openapi: 3.0.3
info: {title: 四类日历整柜到仓来源, version: 1.0.0}
security: [{bearerAuth: []}]
paths:
/api/platform/v1/last-leg/appointment-calendar/warehouse-arrivals/query:
post:
summary: 查询日历整柜人工到仓计划与冻结事实
description: >-
仅平台,同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。
返回所选来源柜,以及查询区间内已人工维护或已有入库计划的到仓记录,支持跨月人工改期。
同柜多个客户合并一条。plannedAt只有整柜时间一致且人工维护版本大于0或已经建计划时才有值。
未人工维护、未建计划的日历默认日期由到港来源加2个自然日;此只读接口不写入库计划、不补造时刻。
actualAt只读PDA首次收货时间,不替换计划日期。
具备原尾程订单编辑权限、版本一致、无入库计划且未实际到仓时canEdit=true。
保存复用overseas-business-v1的整柜expected-arrival入口和原版本、幂等键、冻结门禁。
无法核平的整柜版本为-1,不能用默认日期覆盖。最多2000条,超出返回400。
x-permissions: [platform:appointment:list, 'platform:overseas-warehouse:last-mile-order:list']
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [from, to, sourceContainerBookingIds]
properties:
from: {type: string, format: date}
to: {type: string, format: date, description: 左闭右开,最多42天}
sourceContainerBookingIds: {type: array, maxItems: 2000, uniqueItems: true, items: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200':
description: 成功业务响应,code为字符串200
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data:
type: array
items:
type: object
properties:
sourceContainerBookingId: {type: string}
containerNo: {type: string, nullable: true}
warehouseName: {type: string, nullable: true}
warehouseCode: {type: string, nullable: true}
plannedAt: {type: string, format: date-time, nullable: true}
actualAt: {type: string, format: date-time, nullable: true}
version: {type: integer, minimum: -1}
planCount: {type: integer, minimum: 0}
canEdit: {type: boolean}
'400': {description: 日期、来源柜或数量不符合约束}
'403': {description: 非平台或缺少任一读取权限}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
@@ -0,0 +1,39 @@
package com.baiyue.lastleg.calendar;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class WarehouseCalendarMapperTest {
/** 同柜跨客户不重复,人工改期与已建计划日期分别保留。 */
@Test void deduplicatesCustomersPreservesManualAndFrozenDatesAndFindsCrossMonthRescheduling() throws Exception {
var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:warehouse-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var connection=ds.getConnection(); var sql=connection.createStatement()) {
sql.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_container_booking_id VARCHAR,container_no VARCHAR," +
"overseas_warehouse_id VARCHAR,overseas_warehouse_name VARCHAR,overseas_warehouse_code VARCHAR," +
"estimated_warehouse_at TIMESTAMP,arrival_time_version INT)");
sql.execute("CREATE TABLE ll_inbound_plan(source_order_id BIGINT,customer_id VARCHAR)");
sql.execute("CREATE TABLE ll_receiving_container_session(source_container_booking_id VARCHAR,receiving_at TIMESTAMP)");
sql.execute("INSERT INTO ll_order VALUES(1,'A','1','CONT1','W','Warehouse','W','2026-10-01 09:00:00',0)," +
"(2,'B','1','CONT1','W','Warehouse','W','2026-10-01 09:00:00',0)," +
"(3,'A','2','CONT2','W','Warehouse','W','2026-11-10 09:00:00',2)," +
"(4,'A','3','CONT3','W','Warehouse','W','2026-11-12 09:00:00',0)");
sql.execute("INSERT INTO ll_inbound_plan VALUES(4,'A')");
}
var config=new MybatisConfiguration(); config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));
config.addMapper(WarehouseCalendarMapper.class);
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var rows=session.getMapper(WarehouseCalendarMapper.class).calendar(LocalDateTime.parse("2026-11-01T00:00:00"),LocalDateTime.parse("2026-12-01T00:00:00"),List.of("1"));
assertEquals(3,rows.size());
assertEquals("1",rows.get(0).sourceContainerBookingId()); assertNull(rows.get(0).plannedAt());
assertEquals(2,rows.get(1).version()); assertEquals("2026-11-10T09:00",rows.get(1).plannedAt().toString());
assertEquals(1,rows.get(2).planCount()); assertNotNull(rows.get(2).plannedAt());
}
}
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.calendar;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class WarehouseCalendarServiceTest {
/** 读取资格不代替写权限,已有入库计划仍冻结时间。 */
@Test void keepsReadAccessSeparateFromWriteAndFreezesExistingPlans() {
var repository=mock(WarehouseCalendarRepository.class); var service=new WarehouseCalendarService(repository);
var query=new WarehouseCalendarModels.Query(LocalDate.of(2026,11,1),LocalDate.of(2026,12,1),List.of("1"));
var open=new WarehouseCalendarModels.Entry("1","CONT","Warehouse","W",null,null,0,0,false);
var frozen=new WarehouseCalendarModels.Entry("2","CONT2","Warehouse","W",null,null,1,1,false);
when(repository.calendar(query)).thenReturn(List.of(open,frozen));
assertTrue(service.calendar(query,true).get(0).canEdit()); assertFalse(service.calendar(query,true).get(1).canEdit());
assertTrue(service.calendar(query,false).stream().noneMatch(WarehouseCalendarModels.Entry::canEdit));
assertThrows(ResponseStatusException.class,()->service.calendar(new WarehouseCalendarModels.Query(query.from(),query.to(),List.of("1","1")),true));
}
}
@@ -46,7 +46,7 @@ class FleetTripApiContractTest {
count++;
}
}
assertThat(count).isEqualTo(53);
assertThat(count).isEqualTo(54);
verifyRefs(document, document);
verifyRefs(preparation, preparation);
verifyRefs(pool, pool);
@@ -87,6 +87,8 @@ class FleetTripAuthorizationTest {
}
@Configuration @EnableMethodSecurity
static class Config {
/** 既有取货能力依赖原仓储引用;权限测试使用只读替身,不连接数据库。 */
@Bean com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups() { return mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); }
@Bean com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workTripChanges() { return mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class); }
@Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort workDispatches() { return mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class); }
@Bean FleetTripCalendarService calendar() { return mock(FleetTripCalendarService.class); }