From aa195cef66b347b78ae3c34584b67a098bd828c4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 19 Oct 2026 12:15:43 +0800 Subject: [PATCH] feat(appointments): add four-type calendar sources and align release contracts --- deploy/sql/README.md | 2 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 62 ------------------- deploy/sql/海外区域统一增量-MySQL8.sql | 57 ++++++++++++++++- .../ContainerCalendarSourceController.java | 28 +++++++++ .../dto/ContainerCalendarSource.java | 8 +++ .../mapper/ContainerCalendarSourceMapper.java | 44 +++++++++++++ .../ContainerCalendarSourceRepository.java | 19 ++++++ .../ContainerCalendarSourceService.java | 46 ++++++++++++++ .../appointment-calendar-sources-v1.yaml | 55 ++++++++++++++++ .../ContainerCalendarSourceMapperTest.java | 44 +++++++++++++ .../ContainerCalendarSourceServiceTest.java | 45 ++++++++++++++ .../calendar/WarehouseCalendarController.java | 29 +++++++++ .../calendar/WarehouseCalendarMapper.java | 42 +++++++++++++ .../calendar/WarehouseCalendarModels.java | 20 ++++++ .../calendar/WarehouseCalendarRepository.java | 15 +++++ .../calendar/WarehouseCalendarService.java | 27 ++++++++ .../appointment-calendar-warehouse-v1.yaml | 56 +++++++++++++++++ .../calendar/WarehouseCalendarMapperTest.java | 39 ++++++++++++ .../WarehouseCalendarServiceTest.java | 22 +++++++ .../fleet/trip/FleetTripApiContractTest.java | 2 +- .../trip/FleetTripAuthorizationTest.java | 2 + 21 files changed, 599 insertions(+), 65 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/ContainerCalendarSourceController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerCalendarSource.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/repository/ContainerCalendarSourceRepository.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceService.java create mode 100644 firstleg/src/main/resources/openapi/appointment-calendar-sources-v1.yaml create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceServiceTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarService.java create mode 100644 lastleg-service/src/main/resources/openapi/appointment-calendar-warehouse-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarServiceTest.java diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 09da10211..24ec7e9e8 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -45,7 +45,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 227 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 215 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 220 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 79598ecf6..d58001da3 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -8366,65 +8366,3 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3)); SELECT 'PASS' AS initialization_status, 'CN' AS region, 227 AS initialized_tables; - --- 2026-10-17 仓储转运上门取货,目标 baiyue_lastleg。 --- 目标数据库:baiyue_lastleg;仅增加结构,不修改既有业务数据,可重复执行。 -USE baiyue_lastleg; -SET @pickup_column_sql = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_fleet_trip' AND column_name='trip_purpose')=0, - 'ALTER TABLE ll_fleet_trip ADD COLUMN trip_purpose VARCHAR(16) NOT NULL DEFAULT ''DELIVERY'' COMMENT ''DELIVERY出库派送 PICKUP上门取货入仓''', - 'SELECT 1'); -PREPARE pickup_column_stmt FROM @pickup_column_sql; -EXECUTE pickup_column_stmt; -DEALLOCATE PREPARE pickup_column_stmt; - -CREATE TABLE IF NOT EXISTS ll_fleet_pickup_plan ( - trip_no VARCHAR(64) NOT NULL COMMENT '现有车辆车次号', - request_hash VARCHAR(64) NOT NULL COMMENT '创建来源与计划摘要', - PRIMARY KEY(trip_no) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运取货计划'; - -CREATE TABLE IF NOT EXISTS ll_fleet_pickup_task ( - transfer_order_id BIGINT NOT NULL COMMENT '原仓储转运单ID,一票唯一', - customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键', - transfer_no VARCHAR(64) NOT NULL COMMENT '原仓储转运单号', - trip_no VARCHAR(64) NULL COMMENT '当前取货车次,解除后为空', - status VARCHAR(32) NOT NULL COMMENT 'WAITING待安排 ASSIGNED已安排 PICKED已取货 ARRIVED已送仓', - actual_packages INT NULL COMMENT '司机实提箱数,非仓库实收', - picked_at DATETIME NULL COMMENT '实际确认取货UTC时间', - arrived_at DATETIME NULL COMMENT '实际确认送仓UTC时间', - evidence_json LONGTEXT NULL COMMENT '本票已确认凭证元数据', - remark VARCHAR(1000) NULL COMMENT '取货备注', - updated_at DATETIME NOT NULL COMMENT '最近修改UTC时间', - PRIMARY KEY(transfer_order_id), - UNIQUE KEY uk_pickup_source(customer_id,transfer_no), - KEY idx_pickup_trip(trip_no,status) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运逐票取货执行'; - -CREATE TABLE IF NOT EXISTS ll_fleet_pickup_command ( - trip_no VARCHAR(64) NOT NULL COMMENT '取货车次号', - request_key VARCHAR(128) NOT NULL COMMENT '成功命令幂等键', - request_hash VARCHAR(64) NOT NULL COMMENT '命令动作与内容摘要', - PRIMARY KEY(trip_no,request_key) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货命令幂等记录'; - -CREATE TABLE IF NOT EXISTS ll_fleet_pickup_upload ( - transfer_order_id BIGINT NOT NULL COMMENT '凭证所属原单', - trip_no VARCHAR(64) NOT NULL COMMENT '凭证所属车次', - file_id BIGINT NOT NULL COMMENT '平台归属文件ID', - PRIMARY KEY(file_id), - KEY idx_pickup_upload_source(transfer_order_id,trip_no) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货上传凭证精确单据归属'; - -CREATE TABLE IF NOT EXISTS ll_fleet_pickup_history ( - id BIGINT NOT NULL AUTO_INCREMENT COMMENT '操作流水', - customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键', - transfer_no VARCHAR(64) NOT NULL COMMENT '原转运单号', - trip_no VARCHAR(64) NOT NULL COMMENT '当时取货车次号', - action VARCHAR(32) NOT NULL COMMENT '安排解除取货到仓动作', - operator_id VARCHAR(64) NOT NULL COMMENT '操作人', - occurred_at DATETIME NOT NULL COMMENT '操作UTC时间', - detail VARCHAR(2000) NULL COMMENT '操作说明', - PRIMARY KEY(id), - KEY idx_pickup_history_source(customer_id,transfer_no,id), - KEY idx_pickup_history_trip(trip_no,id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史'; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 2cb992618..82e10bf4b 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -2928,6 +2928,7 @@ CREATE TABLE ll_fleet_vehicle_record_history ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录不可变历史'; CREATE TABLE ll_fleet_trip ( + trip_purpose VARCHAR(16) NOT NULL DEFAULT 'DELIVERY' COMMENT 'DELIVERY出库派送 PICKUP上门取货入仓', id BIGINT NOT NULL COMMENT '内部主键', trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号', idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键', @@ -4657,4 +4658,58 @@ CREATE TABLE ll_compact_number_clock ( PRIMARY KEY (number_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 215 AS initialized_tables; +-- 仓储转运上门取货表归尾程服务,仅用于空库初始化。 +USE baiyue_lastleg; +CREATE TABLE ll_fleet_pickup_plan ( + trip_no VARCHAR(64) NOT NULL COMMENT '现有车辆车次号', + request_hash VARCHAR(64) NOT NULL COMMENT '创建来源与计划摘要', + PRIMARY KEY(trip_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运取货计划'; + +CREATE TABLE ll_fleet_pickup_task ( + transfer_order_id BIGINT NOT NULL COMMENT '原仓储转运单ID,一票唯一', + customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键', + transfer_no VARCHAR(64) NOT NULL COMMENT '原仓储转运单号', + trip_no VARCHAR(64) NULL COMMENT '当前取货车次,解除后为空', + status VARCHAR(32) NOT NULL COMMENT 'WAITING待安排 ASSIGNED已安排 PICKED已取货 ARRIVED已送仓', + actual_packages INT NULL COMMENT '司机实提箱数,非仓库实收', + picked_at DATETIME NULL COMMENT '实际确认取货UTC时间', + arrived_at DATETIME NULL COMMENT '实际确认送仓UTC时间', + evidence_json LONGTEXT NULL COMMENT '本票已确认凭证元数据', + remark VARCHAR(1000) NULL COMMENT '取货备注', + updated_at DATETIME NOT NULL COMMENT '最近修改UTC时间', + PRIMARY KEY(transfer_order_id), + UNIQUE KEY uk_pickup_source(customer_id,transfer_no), + KEY idx_pickup_trip(trip_no,status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运逐票取货执行'; + +CREATE TABLE ll_fleet_pickup_command ( + trip_no VARCHAR(64) NOT NULL COMMENT '取货车次号', + request_key VARCHAR(128) NOT NULL COMMENT '成功命令幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '命令动作与内容摘要', + PRIMARY KEY(trip_no,request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货命令幂等记录'; + +CREATE TABLE ll_fleet_pickup_upload ( + transfer_order_id BIGINT NOT NULL COMMENT '凭证所属原单', + trip_no VARCHAR(64) NOT NULL COMMENT '凭证所属车次', + file_id BIGINT NOT NULL COMMENT '平台归属文件ID', + PRIMARY KEY(file_id), + KEY idx_pickup_upload_source(transfer_order_id,trip_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货上传凭证精确单据归属'; + +CREATE TABLE ll_fleet_pickup_history ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '操作流水', + customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键', + transfer_no VARCHAR(64) NOT NULL COMMENT '原转运单号', + trip_no VARCHAR(64) NOT NULL COMMENT '当时取货车次号', + action VARCHAR(32) NOT NULL COMMENT '安排解除取货到仓动作', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME NOT NULL COMMENT '操作UTC时间', + detail VARCHAR(2000) NULL COMMENT '操作说明', + PRIMARY KEY(id), + KEY idx_pickup_history_source(customer_id,transfer_no,id), + KEY idx_pickup_history_trip(trip_no,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史'; + +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 220 AS initialized_tables; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/ContainerCalendarSourceController.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/ContainerCalendarSourceController.java new file mode 100644 index 000000000..67ec93b9c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/ContainerCalendarSourceController.java @@ -0,0 +1,28 @@ +package com.baiyue.firstleg.terminaldrayage.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import com.baiyue.firstleg.terminaldrayage.service.ContainerCalendarSourceService; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; + +@RestController +@RequestMapping("/api/platform/v1/terminal-drayages") +public class ContainerCalendarSourceController { + private final ContainerCalendarSourceService service; + /** 装配只读日历业务服务。 */ + public ContainerCalendarSourceController(ContainerCalendarSourceService service) { this.service = service; } + /** 预约列表可读基础柜信息,还柜事实继续核验原业务读取权限。 */ + @GetMapping("/calendar-sources") + @PreAuthorize("hasAuthority('platform:appointment:list')") + public ApiResponse calendar(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.calendar(from, to, user.permissions())); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerCalendarSource.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerCalendarSource.java new file mode 100644 index 000000000..110682deb --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerCalendarSource.java @@ -0,0 +1,8 @@ +package com.baiyue.firstleg.terminaldrayage.dto; + +import java.time.LocalDateTime; + +/** 四类日历使用的柜级来源,日期均保留原业务当地值。 */ +public record ContainerCalendarSource(Long containerBookingId, String cabinetNo, String containerNo, + String vesselName, String voyageNo, String destinationPortName, String destinationWarehouseCode, + LocalDateTime arrivalAt, String arrivalType) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapper.java new file mode 100644 index 000000000..00c852160 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapper.java @@ -0,0 +1,44 @@ +package com.baiyue.firstleg.terminaldrayage.mapper; + +import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource; +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 ContainerCalendarSourceMapper { + /** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */ + @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.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 + FROM fl_container_booking c + LEFT JOIN fl_terminal_drayage t ON t.container_booking_id=c.id + LEFT JOIN fl_carrier_schedule_node_fact n ON n.id=( + SELECT f.id FROM fl_carrier_schedule_node_fact f WHERE f.container_booking_id=c.id + AND f.standard_node='ARRIVED_DESTINATION_PORT' AND f.actual_at IS NOT NULL + ORDER BY f.observed_at DESC,f.id DESC LIMIT 1) + WHERE c.record_status='ACTIVE' + AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d WHERE d.container_booking_id=c.id + AND d.confirmed_at IS NOT NULL AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING') + AND ( + COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) IS NULL + OR (COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at)>=#{arrivalFrom} + AND COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at)<#{to}) + OR (t.actual_empty_return_at>=#{from} AND t.actual_empty_return_at<#{to}) + OR (c.unloading_completed_at>=#{returnFrom} AND c.unloading_completed_at<#{to}) + OR EXISTS(SELECT 1 FROM fl_carrier_schedule_node_fact r WHERE r.container_booking_id=c.id + AND r.standard_node='EMPTY_RETURNED' AND r.actual_at>=#{from} AND r.actual_at<#{to}) + OR EXISTS(SELECT 1 FROM fl_direct_delivery_unloading u WHERE u.container_booking_id=c.id + AND u.actual_at>=#{returnFrom} AND u.actual_at<#{to}) + ) + ORDER BY c.id LIMIT 2001 + """) + List sources(@Param("from") LocalDateTime from, + @Param("arrivalFrom") LocalDateTime arrivalFrom, @Param("returnFrom") LocalDateTime returnFrom, + @Param("to") LocalDateTime to); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/repository/ContainerCalendarSourceRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/repository/ContainerCalendarSourceRepository.java new file mode 100644 index 000000000..9e6588345 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/repository/ContainerCalendarSourceRepository.java @@ -0,0 +1,19 @@ +package com.baiyue.firstleg.terminaldrayage.repository; + +import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource; +import com.baiyue.firstleg.terminaldrayage.mapper.ContainerCalendarSourceMapper; +import org.springframework.stereotype.Repository; +import java.time.LocalDate; +import java.util.List; + +@Repository +public class ContainerCalendarSourceRepository { + private final ContainerCalendarSourceMapper mapper; + /** 使用本服务 Mapper 读取日历所需柜级来源。 */ + public ContainerCalendarSourceRepository(ContainerCalendarSourceMapper mapper) { this.mapper = mapper; } + /** 到港前移两天覆盖默认到仓,直送还柜候选额外留一天覆盖夏令时。 */ + public List sources(LocalDate from, LocalDate to) { + return mapper.sources(from.atStartOfDay(), from.minusDays(2).atStartOfDay(), + from.minusDays(4).atStartOfDay(), to.atStartOfDay()); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceService.java new file mode 100644 index 000000000..4903f8062 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceService.java @@ -0,0 +1,46 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View; +import com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.*; +import org.springframework.web.server.ResponseStatusException; + +@Service +public class ContainerCalendarSourceService { + public record Result(List containers, List emptyReturns, + boolean emptyReturnsReadable) { } + private final ContainerCalendarSourceRepository repository; + private final TerminalDrayageService drayages; + /** 复用真实还柜业务的截止时间和完成事实,不在日历另算还柜状态。 */ + public ContainerCalendarSourceService(ContainerCalendarSourceRepository repository, TerminalDrayageService drayages) { + this.repository = repository; this.drayages = drayages; + } + /** 按日期范围读取到港、默认到仓来源及可见的还柜记录。 */ + public Result calendar(LocalDate from, LocalDate to, Set permissions) { + if (from == null || to == null || !to.isAfter(from) || ChronoUnit.DAYS.between(from, to) > 42) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历查询范围必须为1至42天"); + var sources = repository.sources(from, to); + if (sources.size() > 2000) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历来源超过2000柜,请缩小范围或补齐未定日期"); + boolean readable = permissions != null && permissions.contains("platform:firstleg:terminal-drayage:list"); + var returns = new ArrayList(); + if (readable) { + var ids = sources.stream().map(ContainerCalendarSource::containerBookingId).toList(); + for (int start = 0; start < ids.size(); start += 200) { + for (var row : drayages.authorize(drayages.views(ids.subList(start, Math.min(start + 200, ids.size()))), permissions)) { + var at = row.actualEmptyReturnAt() != null ? row.actualEmptyReturnAt() : row.emptyReturnWarning().deadlineAt(); + if (at != null && !at.toLocalDate().isBefore(from) && at.toLocalDate().isBefore(to)) returns.add(row); + } + } + } + // 未授权还柜读取时,也不能通过仅因还柜日期命中的旧柜候选推断受保护事实。 + var visibleSources = readable ? sources : sources.stream().filter(row -> row.arrivalAt() == null + || !row.arrivalAt().toLocalDate().isBefore(from.minusDays(2)) && row.arrivalAt().toLocalDate().isBefore(to)).toList(); + return new Result(List.copyOf(visibleSources), List.copyOf(returns), readable); + } +} diff --git a/firstleg/src/main/resources/openapi/appointment-calendar-sources-v1.yaml b/firstleg/src/main/resources/openapi/appointment-calendar-sources-v1.yaml new file mode 100644 index 000000000..2faa734e9 --- /dev/null +++ b/firstleg/src/main/resources/openapi/appointment-calendar-sources-v1.yaml @@ -0,0 +1,55 @@ +openapi: 3.0.3 +info: {title: 四类日历柜级来源, version: 1.0.0} +security: [{bearerAuth: []}] +paths: + /api/platform/v1/terminal-drayages/calendar-sources: + get: + summary: 查询日历到港、默认到仓来源和还柜事实 + description: >- + 仅平台,platform:appointment:list。返回已确认国内出仓、未撤销且有效的柜次。 + 到港取最新已观测实际到港节点、柜次实际到港、预计到港,按此顺序选一个日期。 + 到港候选前移两天覆盖计划到仓默认日期;缺到港日期的柜次同时返回,不能伪造日期。 + 还柜候选含原截止日期与真实完成日期,最终由原码头拖车业务复核,完成后只按实际还柜日期归日历。 + 没有platform:firstleg:terminal-drayage:list时emptyReturns为空且emptyReturnsReadable=false;不把缺权限当没有安排。 + 最多2000个候选,超出返回400;来源读取失败返回错误,不以空列表代替。 + x-permissions: [platform:appointment:list] + parameters: + - {name: from, in: query, required: true, schema: {type: string, format: date}, description: 包含的原业务当地日期} + - {name: to, in: query, required: true, schema: {type: string, format: date}, description: 不包含的原业务当地日期,范围最多42天} + responses: + '200': + description: 成功业务响应,code为字符串200 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: + type: object + required: [containers, emptyReturns, emptyReturnsReadable] + properties: + emptyReturnsReadable: {type: boolean} + containers: + type: array + items: + type: object + properties: + containerBookingId: {type: string} + cabinetNo: {type: string, nullable: true} + containerNo: {type: string, nullable: true} + vesselName: {type: string, nullable: true} + voyageNo: {type: string, nullable: true} + destinationPortName: {type: string, nullable: true} + destinationWarehouseCode: {type: string, nullable: true} + arrivalAt: {type: string, format: date-time, nullable: true} + arrivalType: {type: string, enum: [ACTUAL, ESTIMATED]} + emptyReturns: + type: array + description: 沿terminal-drayage-v1的View结构,allowedActions按当前权限过滤;含actualEmptyReturnAt及emptyReturnWarning.deadlineAt + items: {$ref: './terminal-drayage-v1.yaml#/components/schemas/TerminalDrayageView'} + '400': {description: 范围无效或候选过多} + '403': {description: 非平台或没有预约读取权限} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java new file mode 100644 index 000000000..c5695c497 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java @@ -0,0 +1,44 @@ +package com.baiyue.firstleg.terminaldrayage.mapper; + +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 static org.junit.jupiter.api.Assertions.*; + +class ContainerCalendarSourceMapperTest { + /** 使用实际 Mapper 核对跨月默认日期候选、缺日期与真实出仓边界。 */ + @Test + void selectsShiftedArrivalUndatedAndOlderReturnCandidatesWithoutInactiveOrUndepartedContainers() throws Exception { + var ds = new JdbcDataSource(); + 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," + + "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,cabinet_no,estimated_port_arrival_at,record_status) VALUES " + + "(1,'D1','2026-10-30 12:00:00','ACTIVE'),(2,'D2','2026-11-10 12:00:00','ACTIVE')," + + "(3,'D3',NULL,'ACTIVE'),(4,'D4','2026-01-01 12:00:00','ACTIVE')," + + "(5,'D5','2026-11-10 12:00:00','CANCELLED'),(6,'D6','2026-11-10 12:00:00','ACTIVE')"); + sql.execute("INSERT INTO fl_outbound_dispatch SELECT id,'2026-01-01 00:00:00',NULL,'WAITING_FOR_LOADING' FROM fl_container_booking WHERE id<>6"); + sql.execute("INSERT INTO fl_terminal_drayage VALUES(4,'2026-11-05 12:00:00')"); + sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(1,2,'ARRIVED_DESTINATION_PORT','2026-10-25 12:00:00','2026-10-26 12:00:00')"); + } + 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 from = LocalDateTime.parse("2026-11-01T00:00:00"); + var rows = session.getMapper(ContainerCalendarSourceMapper.class).sources(from, from.minusDays(2), from.minusDays(4), from.plusDays(30)); + assertEquals(java.util.List.of(1L, 3L, 4L), rows.stream().map(row -> row.containerBookingId()).toList()); + assertEquals("2026-10-30T12:00", rows.get(0).arrivalAt().toString()); + assertNull(rows.get(1).arrivalAt()); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceServiceTest.java new file mode 100644 index 000000000..fe5ee0ff0 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceServiceTest.java @@ -0,0 +1,45 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.*; +import com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ContainerCalendarSourceServiceTest { + private final ContainerCalendarSourceRepository repository = mock(ContainerCalendarSourceRepository.class); + private final TerminalDrayageService drayages = mock(TerminalDrayageService.class); + private final ContainerCalendarSourceService service = new ContainerCalendarSourceService(repository, drayages); + private final LocalDate from = LocalDate.of(2026, 11, 1), to = from.plusDays(30); + /** 缺还柜读取权限时保留范围说明,不读取受保护事实。 */ + @Test void keepsMissingReturnPermissionExplicitWithoutReadingProtectedFacts() { + when(repository.sources(from, to)).thenReturn(List.of( + new ContainerCalendarSource(1L,"D1",null,null,null,null,null,null,"ESTIMATED"), + new ContainerCalendarSource(2L,"D2",null,null,null,null,null,from.minusMonths(2).atStartOfDay(),"ACTUAL"))); + var result = service.calendar(from, to, Set.of("platform:appointment:list")); + assertEquals(1, result.containers().size()); assertFalse(result.emptyReturnsReadable()); + verifyNoInteractions(drayages); + } + /** 实际还柜日期优先于截止日期,范围外完成事实不回落到原提醒日。 */ + @Test void usesOriginalDeadlineButActualReturnSupersedesTheDeadlineDate() { + when(repository.sources(from, to)).thenReturn(List.of(new ContainerCalendarSource(1L,"D1",null,null,null,null,null,null,"ESTIMATED"))); + var row = mock(View.class); + when(row.actualEmptyReturnAt()).thenReturn(to.atStartOfDay()); + when(row.emptyReturnWarning()).thenReturn(new Warning("RESOLVED", from.atStartOfDay(),0,0)); + when(drayages.views(List.of(1L))).thenReturn(List.of(row)); + when(drayages.authorize(anyList(), anySet())).thenAnswer(call -> call.getArgument(0)); + assertTrue(service.calendar(from,to,Set.of("platform:firstleg:terminal-drayage:list")).emptyReturns().isEmpty()); + when(row.actualEmptyReturnAt()).thenReturn(null); + assertEquals(List.of(row), service.calendar(from,to,Set.of("platform:firstleg:terminal-drayage:list")).emptyReturns()); + } + /** 非法日期范围在访问持久化层前拒绝。 */ + @Test void rejectsInvalidRangesBeforeDatabaseAccess() { + assertThrows(ResponseStatusException.class, () -> service.calendar(from,from,Set.of())); + assertThrows(ResponseStatusException.class, () -> service.calendar(from,from.plusDays(43),Set.of())); + verifyNoInteractions(repository); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarController.java new file mode 100644 index 000000000..d5b1cbb5d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarController.java @@ -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> 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))); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapper.java new file mode 100644 index 000000000..b3666a448 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapper.java @@ -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(""" + + """) + List calendar(@Param("from") LocalDateTime from, + @Param("to") LocalDateTime to, @Param("ids") List ids); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarModels.java new file mode 100644 index 000000000..84672c842 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarModels.java @@ -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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarRepository.java new file mode 100644 index 000000000..8051ff96a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarRepository.java @@ -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 calendar(WarehouseCalendarModels.Query query) { + return mapper.calendar(query.from().atStartOfDay(), query.to().atStartOfDay(), query.sourceContainerBookingIds()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarService.java new file mode 100644 index 000000000..25b1fc487 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarService.java @@ -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 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(); + } +} diff --git a/lastleg-service/src/main/resources/openapi/appointment-calendar-warehouse-v1.yaml b/lastleg-service/src/main/resources/openapi/appointment-calendar-warehouse-v1.yaml new file mode 100644 index 000000000..02ad3504d --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/appointment-calendar-warehouse-v1.yaml @@ -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} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java new file mode 100644 index 000000000..2e84dc2b9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java @@ -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()); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarServiceTest.java new file mode 100644 index 000000000..279e6902c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarServiceTest.java @@ -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)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java index 1d8750c3c..6d26a90cd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java @@ -46,7 +46,7 @@ class FleetTripApiContractTest { count++; } } - assertThat(count).isEqualTo(53); + assertThat(count).isEqualTo(54); verifyRefs(document, document); verifyRefs(preparation, preparation); verifyRefs(pool, pool); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java index 2998c50ce..7c434ea61 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java @@ -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); }