diff --git a/deploy/sql/README.md b/deploy/sql/README.md index dec949876..881d90f8e 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,7 @@ # 单测试环境数据库空库初始化 +`reset-test-fba-appointments.sql` 仅用于明确授权的 TEST 预约模型重建:删除并按当前基线重建 `ll_inbound_fba_appointment`、`ll_inbound_fba_appointment_item`。它会丢弃旧预约及关联明细,但不修改订单、入仓计划、库存和排车。调用者必须核对测试连接并取得对这两张表的具体重置授权;不得作为自动启动脚本或用于生产。结构必须与匹配的 Lastleg 新版一同发布,失败立即停止,不对旧数据回填。权限从 IAM 种子核对;本文件存在不表示已执行。 + `migrations/overseas/baiyue_lastleg/V13__widen_inbound_plan_creation_mode.sql` 仅将 `ll_inbound_plan.creation_mode` 从旧统一基线的 `VARCHAR(16)` 扩至模块基线已有的 `VARCHAR(32)`,容纳实际来货审核写入的 `WAREHOUSE_TRANSFER`(18字符)。保留 `utf8mb4_unicode_ci`、非空及 `AUTO` 默认值;不更新已有计划或状态。执行前核对目标库该列的长度、排序规则、可空性和默认值,已符合最终定义则核对后跳过;执行结果不确定时先只读复查,不盲目重试。统一空库基线同步最终定义,不重放模块历史初始化脚本。 `migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql` 仅放开散板打盘需求及两类本土订单柜型三列为空、默认空,供未提供这些事实的拼车商业订单使用;普通订单输入校验仍由原服务端请求合同执行。对应源及统一空库基线同步最终定义,不回填已生成订单或把托盘数量解释成打盘服务需求。 diff --git a/deploy/sql/reset-test-fba-appointments.sql b/deploy/sql/reset-test-fba-appointments.sql new file mode 100644 index 000000000..2ec1ca77f --- /dev/null +++ b/deploy/sql/reset-test-fba-appointments.sql @@ -0,0 +1,41 @@ +-- TEST ONLY. Destructive reset of the two disposable appointment tables. +-- Requires explicit approval; never run against production or as automatic startup SQL. +-- Caller must verify the TEST connection before executing. +USE baiyue_lastleg; +DROP TABLE ll_inbound_fba_appointment_item; +DROP TABLE ll_inbound_fba_appointment; +CREATE TABLE ll_inbound_fba_appointment ( + id BIGINT NOT NULL COMMENT '主键ID', + appointment_no VARCHAR(64) NOT NULL COMMENT '预约单号', + appointment_at DATETIME(3) NOT NULL COMMENT '预约时间', + appointment_end_at DATETIME(3) NULL COMMENT '预约结束时间,未明确时为空', + version INT NOT NULL DEFAULT 0 COMMENT '并发版本', + expected_loading_at DATETIME(3) NOT NULL COMMENT '预计装车时间', + appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '预约状态', + appointment_remark VARCHAR(1000) NULL COMMENT '预约备注', + fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + selected_count INT NOT NULL DEFAULT 0 COMMENT '已选明细数量', + created_by VARCHAR(128) NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_fba_appointment_no (appointment_no), + KEY idx_ll_fba_appointment_list (appointment_at, appointment_status, fba_warehouse_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA入仓预约单'; + +CREATE TABLE ll_inbound_fba_appointment_item ( + id BIGINT NOT NULL COMMENT '主键ID', + appointment_id BIGINT NOT NULL COMMENT '预约单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '本票货物客户ID', + plan_id BIGINT NOT NULL COMMENT '入仓计划ID', + line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', + order_id BIGINT NOT NULL COMMENT '订单ID', + inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_fba_appointment_item_line (appointment_id, line_id), + KEY idx_ll_fba_appointment_item_cargo (line_id,appointment_id), + KEY idx_ll_fba_appointment_item_plan (plan_id), + KEY idx_ll_fba_appointment_item_order (order_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA入仓预约明细'; diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index e9d82747c..0f8036fcd 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -6706,6 +6706,10 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107001,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107002,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107003,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107240,0,'PLATFORM','MENU',0,'platform:appointment:list','预约列表','/appointment-calendar','GET',740,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107240,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107241,0,'PLATFORM','BUTTON',5107240,'platform:appointment:delete','删除预约',NULL,'DELETE',741,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107241,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107010,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107011,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107012,0,CURRENT_TIMESTAMP(3)); diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 1516e2303..6be8f22f1 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -845,9 +845,10 @@ CREATE TABLE ll_finance_fee_source ( CREATE TABLE ll_inbound_fba_appointment ( id bigint NOT NULL COMMENT '主键ID', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', appointment_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '预约单号', appointment_at datetime(3) NOT NULL COMMENT '预约时间', + appointment_end_at DATETIME(3) NULL COMMENT '预约结束时间,未明确时为空', + version INT NOT NULL DEFAULT 0 COMMENT '并发版本', expected_loading_at datetime(3) NOT NULL COMMENT '预计装车时间', appointment_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '预约状态', appointment_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '预约备注', @@ -857,13 +858,14 @@ CREATE TABLE ll_inbound_fba_appointment ( created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), - UNIQUE KEY uk_ll_fba_appointment_no (customer_id,appointment_no), - KEY idx_ll_fba_appointment_list (customer_id,appointment_status,fba_warehouse_code,created_at) + UNIQUE KEY uk_ll_fba_appointment_no (appointment_no), + KEY idx_ll_fba_appointment_list (appointment_at,appointment_status,fba_warehouse_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA入仓预约单'; CREATE TABLE ll_inbound_fba_appointment_item ( id bigint NOT NULL COMMENT '主键ID', appointment_id bigint NOT NULL COMMENT '预约单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本票货物客户ID', plan_id bigint NOT NULL COMMENT '入仓计划ID', line_id bigint NOT NULL COMMENT '入仓计划明细ID', order_id bigint NOT NULL COMMENT '订单ID', @@ -872,6 +874,7 @@ CREATE TABLE ll_inbound_fba_appointment_item ( created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_fba_appointment_item_line (appointment_id,line_id), + KEY idx_ll_fba_appointment_item_cargo (line_id,appointment_id), KEY idx_ll_fba_appointment_item_plan (plan_id), KEY idx_ll_fba_appointment_item_order (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA入仓预约明细'; diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 4cc9b3c76..2d60e5d5e 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -9,6 +9,8 @@ ## 模块入口 +预约日历到港只读:`portschedule/controller/PlatformPortScheduleController.java` 的 `/arrival-calendar`,沿 `PortScheduleService`、`PortScheduleMapper` 查询有效柜次及实际到港节点;合同 `src/main/resources/openapi/arrival-calendar-v1.yaml`,权限 `platform:appointment:list`。不在日历维护第二套船节点。 + 表内 `controller/`、`service/`、`mapper/`、`loading/` 等业务包路径相对 `src/main/java/com/baiyue/firstleg/`;`src/`、`docs/`、`scripts/` 和 `../` 开头路径相对本服务目录。裸文件名沿同一条目最近业务包定位,必要时在该包用 `rg --files` 查找,不从全仓猜路径。 事件人工重试:`controller/EventOperationsController.java` 提供内部 `/internal/firstleg/event-operations/{entryId}` 查询及 `/retry` 写入,只允许装柜完成与船节点事件;独立 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可修改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务提交,脚本见根 `scripts/event-outbox-operations.mjs`。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java index 3f2738a75..15b163b86 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java @@ -37,6 +37,18 @@ import java.util.List; public class PlatformPortScheduleController { @Resource private PortScheduleService service; + @GetMapping("/arrival-calendar") + @PreAuthorize("hasAuthority('platform:appointment:list')") + public ApiResponse> arrivalCalendar( + @AuthenticationPrincipal AuthenticatedUser user, + @org.springframework.web.bind.annotation.RequestParam + @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate from, + @org.springframework.web.bind.annotation.RequestParam + @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate to) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.arrivalCalendar(from, to)); + } + @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:port-schedule:list')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/ArrivalCalendarEntry.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/ArrivalCalendarEntry.java new file mode 100644 index 000000000..e27bc347d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/ArrivalCalendarEntry.java @@ -0,0 +1,7 @@ +package com.baiyue.firstleg.portschedule.dto; + +import java.time.LocalDateTime; + +public record ArrivalCalendarEntry(Long containerBookingId, String bookingNo, String containerNo, + String vesselName, String voyageNo, String destinationPortName, String destinationWarehouseCode, + LocalDateTime arrivalAt, String arrivalType) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/PortScheduleMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/PortScheduleMapper.java index 7b2b4c5b6..2dabdd692 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/PortScheduleMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/PortScheduleMapper.java @@ -11,6 +11,26 @@ import org.apache.ibatis.annotations.Update; @Mapper public interface PortScheduleMapper extends BaseMapper { + @Select(""" + SELECT c.id containerBookingId,c.booking_no bookingNo,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_carrier_schedule_node_fact n ON n.container_booking_id=c.id + AND n.standard_node='ARRIVED_DESTINATION_PORT' AND n.actual_at IS NOT NULL + AND n.id=(SELECT n2.id FROM fl_carrier_schedule_node_fact n2 + WHERE n2.container_booking_id=c.id AND n2.standard_node='ARRIVED_DESTINATION_PORT' + AND n2.actual_at IS NOT NULL ORDER BY n2.observed_at DESC,n2.id DESC LIMIT 1) + WHERE c.record_status='ACTIVE' + AND COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at)>=#{from} + AND COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at)<#{to} + ORDER BY arrivalAt,c.id + """) + java.util.List arrivalCalendar( + @Param("from") java.time.LocalDateTime from, @Param("to") java.time.LocalDateTime to); + @Select(""" ") - List lockLines(@Param("customerId") String customerId, - @Param("lineIds") List lineIds); + List lockLines(@Param("lineIds") List lineIds); + + @Select("") + long countActiveAppointments(@Param("lineIds") List lineIds, @Param("excludedId") Long excludedId); + + @Select(""" + SELECT COUNT(*) FROM ll_inbound_fba_appointment_item i + JOIN ll_dispatch_item di ON di.order_id=i.order_id AND di.customer_id=i.customer_id + JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id AND d.customer_id=di.customer_id + WHERE i.appointment_id=#{appointmentId} AND d.status<>'CANCELLED' + """) + long countActiveDispatches(@Param("appointmentId") Long appointmentId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java index d572e8e41..ac1bcac0a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java @@ -24,7 +24,7 @@ public interface InboundPlanMapper extends BaseMapper { " AND l.fba_warehouse_code=#{q.fbaWarehouseCode}" + " " + "AND EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id " + - "WHERE ai.plan_id=p.id AND a.customer_id=#{customerId} AND a.appointment_status=#{q.appointmentStatus})" + + "WHERE ai.plan_id=p.id AND ai.customer_id=#{customerId} AND a.appointment_status=#{q.appointmentStatus})" + " AND NOT EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai " + "WHERE ai.plan_id=p.id)" + " AND p.created_at >=#{q.createdFrom}" + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java index c2b77aaef..b6ed84c8d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java @@ -18,14 +18,14 @@ public interface InboundPlanQueryMapper { String FROM_AND_FILTER = " FROM ll_inbound_plan_line l" + " JOIN ll_inbound_plan p ON p.id=l.plan_id" + - " JOIN ll_order o ON o.id=l.order_id" + + " JOIN ll_order o ON o.id=l.order_id AND o.customer_id=p.customer_id" + " AND p.customer_id=#{customerId} AND o.customer_id=#{customerId}" + " LEFT JOIN ll_inbound_fba_appointment_item ai ON ai.line_id=l.id" + " AND ai.id=(SELECT ai2.id FROM ll_inbound_fba_appointment_item ai2" + " JOIN ll_inbound_fba_appointment a2 ON a2.id=ai2.appointment_id" + - " WHERE ai2.line_id=l.id AND a2.customer_id=#{customerId}" + + " WHERE ai2.line_id=l.id AND ai2.customer_id=p.customer_id" + " ORDER BY a2.created_at DESC,ai2.id DESC LIMIT 1)" + - " LEFT JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id AND a.customer_id=#{customerId}" + + " LEFT JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id" + " WHERE 1=1" + " AND p.plan_no LIKE CONCAT('%',#{q.planNo},'%')" + " AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentLineSnapshot.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentLineSnapshot.java index 2244c4d85..ee531a920 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentLineSnapshot.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentLineSnapshot.java @@ -5,6 +5,7 @@ public record FbaAppointmentLineSnapshot( Long planId, Long orderId, String inboundCode, - String fbaWarehouseCode + String fbaWarehouseCode, + String customerId ) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java index 75b3efb7c..1fb564d73 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java @@ -20,6 +20,8 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.time.LocalDateTime; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; import java.util.HashSet; import java.util.List; import java.util.Locale; @@ -32,8 +34,29 @@ public class InboundFbaAppointmentService { @Resource private InboundFbaAppointmentItemMapper itemMapper; @Resource private InboundFbaAppointmentQueryMapper queryMapper; - @Transactional - public FbaAppointmentResponse batchCreate(String customerId, String operatorId, + /** A bounded calendar read uses two batch queries, never one detail query per appointment. */ + @Transactional(readOnly = true) + public List calendar(LocalDate from, LocalDate to) { + if (from == null || to == null || !to.isAfter(from) || ChronoUnit.DAYS.between(from, to) > 42) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历查询范围必须为 1 至 42 天"); + } + List appointments = appointmentMapper.selectList( + new LambdaQueryWrapper() + .ge(InboundFbaAppointment::getAppointmentAt, from.atStartOfDay()) + .lt(InboundFbaAppointment::getAppointmentAt, to.atStartOfDay()) + .orderByAsc(InboundFbaAppointment::getAppointmentAt, InboundFbaAppointment::getId)); + if (appointments.isEmpty()) return List.of(); + var groupedItems = itemMapper.selectList(new LambdaQueryWrapper() + .in(InboundFbaAppointmentItem::getAppointmentId, + appointments.stream().map(InboundFbaAppointment::getId).toList()) + .orderByAsc(InboundFbaAppointmentItem::getLineId)) + .stream().collect(java.util.stream.Collectors.groupingBy(InboundFbaAppointmentItem::getAppointmentId)); + return appointments.stream().map(appointment -> response(appointment, + groupedItems.getOrDefault(appointment.getId(), List.of()))).toList(); + } + + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) + public FbaAppointmentResponse batchCreate(String operatorId, BatchFbaAppointmentRequest request) { List lineIds = List.copyOf(request.lineIds()); if (new HashSet<>(lineIds).size() != lineIds.size()) { @@ -42,31 +65,37 @@ public class InboundFbaAppointmentService { String appointmentNo = requiredTrimmed(request.appointmentNo(), "Appointment number is required"); String status = normalizeStatus(request.appointmentStatus()); String remark = trimToNull(request.appointmentRemark()); + validateWindow(request.appointmentAt(), request.appointmentEndAt()); - InboundFbaAppointment existing = find(customerId, appointmentNo); + InboundFbaAppointment existing = find(appointmentNo); if (existing != null) { - return verifyReplay(existing, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark); + return verifyReplay(existing, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark, request.appointmentEndAt()); } - List lines = queryMapper.lockLines(customerId, lineIds); + List lines = queryMapper.lockLines(lineIds); if (lines.size() != lineIds.size()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, - "One or more selected inbound lines do not exist in this tenant"); + "所选入仓明细不存在,请刷新后重试"); } String fbaWarehouseCode = commonFbaWarehouse(lines); // The row locks serialize concurrent submissions selecting the same inbound lines. - existing = find(customerId, appointmentNo); + existing = find(appointmentNo); if (existing != null) { - return verifyReplay(existing, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark); + return verifyReplay(existing, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark, request.appointmentEndAt()); + } + + if (queryMapper.countActiveAppointments(lineIds, null) > 0) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "所选货物已有有效预约,请编辑原预约或先取消"); } LocalDateTime now = LocalDateTime.now(); InboundFbaAppointment appointment = new InboundFbaAppointment(); appointment.setId(IdWorker.getId()); - appointment.setCustomerId(customerId); appointment.setAppointmentNo(appointmentNo); appointment.setAppointmentAt(request.appointmentAt()); + appointment.setAppointmentEndAt(request.appointmentEndAt()); + appointment.setVersion(0); appointment.setExpectedLoadingAt(request.expectedLoadingAt()); appointment.setAppointmentStatus(status); appointment.setAppointmentRemark(remark); @@ -78,9 +107,9 @@ public class InboundFbaAppointmentService { try { appointmentMapper.insert(appointment); } catch (DuplicateKeyException exception) { - InboundFbaAppointment concurrent = find(customerId, appointmentNo); + InboundFbaAppointment concurrent = find(appointmentNo); if (concurrent != null) { - return verifyReplay(concurrent, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark); + return verifyReplay(concurrent, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark, request.appointmentEndAt()); } throw exception; } @@ -89,6 +118,7 @@ public class InboundFbaAppointmentService { InboundFbaAppointmentItem item = new InboundFbaAppointmentItem(); item.setId(IdWorker.getId()); item.setAppointmentId(appointment.getId()); + item.setCustomerId(line.customerId()); item.setPlanId(line.planId()); item.setLineId(line.lineId()); item.setOrderId(line.orderId()); @@ -101,39 +131,75 @@ public class InboundFbaAppointmentService { } @Transactional(readOnly = true) - public FbaAppointmentResponse detail(String customerId, String appointmentNo) { - InboundFbaAppointment appointment = find(customerId, - requiredTrimmed(appointmentNo, "Appointment number is required")); + public FbaAppointmentResponse detail(String appointmentNo) { + InboundFbaAppointment appointment = find(requiredTrimmed(appointmentNo, "Appointment number is required")); if (appointment == null) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "FBA appointment not found"); } return response(appointment, items(appointment.getId())); } - @Transactional - public FbaAppointmentResponse update(String customerId, String appointmentNo, + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) + public FbaAppointmentResponse update(String appointmentNo, UpdateFbaAppointmentRequest request) { - InboundFbaAppointment appointment = find(customerId, - requiredTrimmed(appointmentNo, "Appointment number is required")); + InboundFbaAppointment appointment = find(requiredTrimmed(appointmentNo, "Appointment number is required")); if (appointment == null) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "FBA appointment not found"); } + String status = normalizeStatus(request.appointmentStatus()); + validateWindow(request.appointmentAt(), request.appointmentEndAt()); + if (request.version() == null || !Objects.equals(appointment.getVersion(), request.version())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "预约已被其他人修改,请重新打开后再保存"); + } + List lineIds = items(appointment.getId()).stream().map(InboundFbaAppointmentItem::getLineId).sorted().toList(); + if (!lineIds.isEmpty()) { + queryMapper.lockLines(lineIds); + if (!Set.of("FAILED", "CANCELLED").contains(status) + && queryMapper.countActiveAppointments(lineIds, appointment.getId()) > 0) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "所选货物已有其他有效预约,不能恢复当前预约"); + } + } appointment.setAppointmentAt(request.appointmentAt()); appointment.setExpectedLoadingAt(request.expectedLoadingAt()); - appointment.setAppointmentStatus(normalizeStatus(request.appointmentStatus())); + appointment.setAppointmentStatus(status); + appointment.setAppointmentEndAt(request.appointmentEndAt()); + appointment.setVersion(request.version() + 1); appointment.setAppointmentRemark(trimToNull(request.appointmentRemark())); appointment.setUpdatedAt(LocalDateTime.now()); - appointmentMapper.updateById(appointment); + if (appointmentMapper.update(appointment, new LambdaQueryWrapper() + .eq(InboundFbaAppointment::getId, appointment.getId()) + .eq(InboundFbaAppointment::getVersion, request.version())) != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "预约已被其他人修改,请重新打开后再保存"); + } return response(appointment, items(appointment.getId())); } + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) + public void delete(String appointmentNo, int version) { + InboundFbaAppointment appointment = find(requiredTrimmed(appointmentNo, "预约编号不能为空")); + if (appointment == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "预约不存在"); + List lineIds = items(appointment.getId()).stream().map(InboundFbaAppointmentItem::getLineId).sorted().toList(); + if (!lineIds.isEmpty()) queryMapper.lockLines(lineIds); + if (queryMapper.countActiveDispatches(appointment.getId()) > 0) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "预约货物已关联排车,不允许删除,请先处理关联排车"); + } + if (appointmentMapper.delete(new LambdaQueryWrapper() + .eq(InboundFbaAppointment::getId, appointment.getId()) + .eq(InboundFbaAppointment::getVersion, version)) != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "预约已被其他人修改,请刷新后重试"); + } + itemMapper.delete(new LambdaQueryWrapper() + .eq(InboundFbaAppointmentItem::getAppointmentId, appointment.getId())); + } + private FbaAppointmentResponse verifyReplay(InboundFbaAppointment existing, List requestedLineIds, LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt, - String status, String remark) { + String status, String remark, LocalDateTime appointmentEndAt) { List existingItems = items(existing.getId()); Set existingLineIds = existingItems.stream() .map(InboundFbaAppointmentItem::getLineId).collect(java.util.stream.Collectors.toSet()); if (!Objects.equals(existing.getAppointmentAt(), appointmentAt) + || !Objects.equals(existing.getAppointmentEndAt(), appointmentEndAt) || !Objects.equals(existing.getExpectedLoadingAt(), expectedLoadingAt) || !Objects.equals(existing.getAppointmentStatus(), status) || !Objects.equals(existing.getAppointmentRemark(), remark) @@ -155,10 +221,9 @@ public class InboundFbaAppointmentService { return warehouses.get(0); } - private InboundFbaAppointment find(String customerId, String appointmentNo) { + private InboundFbaAppointment find(String appointmentNo) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(InboundFbaAppointment::getAppointmentNo, appointmentNo); - if (customerId != null) wrapper.eq(InboundFbaAppointment::getCustomerId, customerId); return appointmentMapper.selectOne(wrapper); } @@ -170,13 +235,20 @@ public class InboundFbaAppointmentService { private FbaAppointmentResponse response(InboundFbaAppointment appointment, List items) { - return new FbaAppointmentResponse(appointment.getId(), appointment.getCustomerId(), + return new FbaAppointmentResponse(appointment.getId(), appointment.getAppointmentNo(), appointment.getAppointmentAt(), appointment.getExpectedLoadingAt(), appointment.getAppointmentStatus(), appointment.getAppointmentRemark(), appointment.getFbaWarehouseCode(), appointment.getSelectedCount(), appointment.getCreatedBy(), appointment.getCreatedAt(), appointment.getUpdatedAt(), items.stream().map(item -> new FbaAppointmentResponse.Item(item.getId(), item.getPlanId(), - item.getLineId(), item.getOrderId(), item.getInboundCode(), item.getFbaWarehouseCode())).toList()); + item.getLineId(), item.getOrderId(), item.getInboundCode(), item.getFbaWarehouseCode(), item.getCustomerId())).toList(), + appointment.getAppointmentEndAt(), appointment.getVersion()); + } + + private static void validateWindow(LocalDateTime start, LocalDateTime end) { + if (start == null || (end != null && !end.isAfter(start))) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "预约结束时间必须晚于开始时间"); + } } private static String normalizeStatus(String value) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java index c5596b255..35769be0a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.security; /** Canonical authorities for overseas browser APIs. */ public final class OverseasPermissions { + public static final String APPOINTMENT_LIST = "platform:appointment:list"; public static final String LAST_MILE_ORDER_LIST = "platform:overseas-warehouse:last-mile-order:list"; public static final String LAST_MILE_ORDER_EDIT = "platform:overseas-warehouse:last-mile-order:edit"; public static final String LAST_MILE_ORDER_EXPORT = "platform:overseas-warehouse:last-mile-order:export"; diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index c59bf9f06..8050d6ba2 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1626,9 +1626,10 @@ CREATE TABLE IF NOT EXISTS ll_pda_inventory_move ( CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( id BIGINT NOT NULL COMMENT '主键ID', - customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', appointment_no VARCHAR(64) NOT NULL COMMENT '预约单号', appointment_at DATETIME(3) NOT NULL COMMENT '预约时间', + appointment_end_at DATETIME(3) NULL COMMENT '预约结束时间,未明确时为空', + version INT NOT NULL DEFAULT 0 COMMENT '并发版本', expected_loading_at DATETIME(3) NOT NULL COMMENT '预计装车时间', appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '预约状态', appointment_remark VARCHAR(1000) NULL COMMENT '预约备注', @@ -1638,13 +1639,14 @@ CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), - UNIQUE KEY uk_ll_fba_appointment_no (customer_id, appointment_no), - KEY idx_ll_fba_appointment_list (customer_id, appointment_status, fba_warehouse_code, created_at) + UNIQUE KEY uk_ll_fba_appointment_no (appointment_no), + KEY idx_ll_fba_appointment_list (appointment_at, appointment_status, fba_warehouse_code) ) ENGINE=InnoDB COMMENT='FBA入仓预约单'; CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item ( id BIGINT NOT NULL COMMENT '主键ID', appointment_id BIGINT NOT NULL COMMENT '预约单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '本票货物客户ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID', line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', order_id BIGINT NOT NULL COMMENT '订单ID', @@ -1653,36 +1655,11 @@ CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_fba_appointment_item_line (appointment_id, line_id), + KEY idx_ll_fba_appointment_item_cargo (line_id,appointment_id), KEY idx_ll_fba_appointment_item_plan (plan_id), KEY idx_ll_fba_appointment_item_order (order_id) ) ENGINE=InnoDB COMMENT='FBA入仓预约明细'; --- Upgrade the pre-customer-scope appointment table created by older deployments. -SET @ddl = (SELECT IF( - NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() - AND table_name='ll_inbound_fba_appointment' AND column_name='customer_id') - AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() - AND table_name='ll_inbound_fba_appointment' AND column_name='tenant_id'), - 'ALTER TABLE ll_inbound_fba_appointment CHANGE COLUMN tenant_id customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', - 'SELECT 1')); -PREPARE fba_appointment_customer_scope FROM @ddl; EXECUTE fba_appointment_customer_scope; DEALLOCATE PREPARE fba_appointment_customer_scope; - -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() - AND table_name='ll_inbound_fba_appointment' AND column_name='expected_loading_at'), - 'SELECT 1', - 'ALTER TABLE ll_inbound_fba_appointment ADD COLUMN expected_loading_at DATETIME(3) NULL COMMENT ''预计装车时间'' AFTER appointment_at')); -PREPARE fba_appointment_expected_loading FROM @ddl; EXECUTE fba_appointment_expected_loading; DEALLOCATE PREPARE fba_appointment_expected_loading; - -UPDATE ll_inbound_fba_appointment -SET expected_loading_at=appointment_at -WHERE expected_loading_at IS NULL; - -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() - AND table_name='ll_inbound_fba_appointment' AND column_name='expected_loading_at' AND is_nullable='YES'), - 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN expected_loading_at DATETIME(3) NOT NULL COMMENT ''预计装车时间''', - 'SELECT 1')); -PREPARE fba_appointment_expected_loading_required FROM @ddl; EXECUTE fba_appointment_expected_loading_required; DEALLOCATE PREPARE fba_appointment_expected_loading_required; - ALTER TABLE ll_loading_order_status_history MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键', MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单 ID', diff --git a/lastleg-service/src/main/resources/openapi/fba-appointment-calendar-v1.yaml b/lastleg-service/src/main/resources/openapi/fba-appointment-calendar-v1.yaml new file mode 100644 index 000000000..e2d974b92 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fba-appointment-calendar-v1.yaml @@ -0,0 +1,147 @@ +openapi: 3.0.3 +info: + title: 跨客户 FBA 预约与日历 + version: 1.0.0 + description: 按同一目的FBA仓合并多个客户的货物,预约号全局唯一。货物明细保留客户归属,只有平台可读取或维护完整预约;不改变库存与客户订单归属。 +security: + - bearerAuth: [] +paths: + /api/platform/v1/last-leg/inbound-plans/fba-appointments/batch: + post: + summary: 为同一目的仓的跨客户入仓明细创建预约 + description: '权限:platform:overseas-warehouse:inbound-order:fba-appointment。平台身份限定。客户归属由服务端入仓计划确定,不接受预约主单customerId;同号同内容重放返回原预约,异内容拒绝。已有有效预约的货物不能重复建约。' + requestBody: + required: true + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/AppointmentWrite'} + - type: object + required: [lineIds, appointmentNo] + properties: + lineIds: {type: array, minItems: 1, maxItems: 500, items: {type: string}} + appointmentNo: {type: string, maxLength: 64} + responses: + '200': {$ref: '#/components/responses/AppointmentResult'} + '403': {description: 非平台身份或无写权限} + /api/platform/v1/last-leg/inbound-plans/fba-appointments/{appointmentNo}: + parameters: + - name: appointmentNo + in: path + required: true + schema: {type: string} + delete: + summary: 删除未关联有效排车的预约 + description: '平台专用,权限platform:appointment:delete。校验当前版本后原子删除预约及预约明细,不删除客户货物。已有关联有效排车时拒绝。' + parameters: + - {name: version, in: query, required: true, schema: {type: integer, minimum: 0}} + responses: + '200': {description: 业务响应,成功code为字符串200;版本冲突或已关联排车返回业务错误} + '403': {description: 无删除权限} + get: + summary: 读取完整跨客户预约 + description: '仅平台,权限:platform:appointment:list 或 platform:overseas-warehouse:inbound-order:list。客户只能经原订单/入仓单接口读取自己的货物,不开放整张预约。' + responses: + '200': {$ref: '#/components/responses/AppointmentResult'} + '403': {description: 无读取权限} + put: + summary: 按版本更新预约 + description: '权限:platform:overseas-warehouse:inbound-order:fba-appointment。平台限定,版本冲突拒绝;取消不删除货物。恢复时重新校验货物是否已被其他有效预约占用。' + requestBody: + required: true + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/AppointmentWrite'} + - type: object + required: [version] + properties: + version: {type: integer, minimum: 0} + responses: + '200': {$ref: '#/components/responses/AppointmentResult'} + '403': {description: 无写权限} + /api/platform/v1/last-leg/inbound-plans/fba-appointments/calendar: + get: + operationId: fbaAppointmentCalendar + summary: 按日期范围查询 FBA 预约 + description: '权限:platform:appointment:list。仅平台身份可读取跨客户预约。固定批量查询预约及关联明细,不逐预约查详情。' + parameters: + - name: from + in: query + required: true + description: 包含该日期零点,以现有预约业务日期为准。 + schema: {type: string, format: date} + - name: to + in: query + required: true + description: 不包含该日期零点;必须晚于from,间隔不超过42天。 + schema: {type: string, format: date} + responses: + '200': + description: 业务响应;成功code为字符串200,空区间返回空数组。 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: + type: array + items: {$ref: '#/components/schemas/Appointment'} + '401': {description: 未登录或会话失效} + '403': {description: 无列表权限} +components: + responses: + AppointmentResult: + description: 业务响应,成功code为字符串200,业务冲突返回对应业务错误码。 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/Appointment'} + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + schemas: + AppointmentWrite: + type: object + required: [appointmentAt, expectedLoadingAt, appointmentStatus] + properties: + appointmentAt: {type: string, description: 预约开始日期时间} + appointmentEndAt: {type: string, nullable: true, description: 明确结束时间时必须晚于开始时间;未知留空,不推算} + expectedLoadingAt: {type: string} + appointmentStatus: {type: string, enum: [PENDING, SUCCESS, FAILED, CANCELLED]} + appointmentRemark: {type: string, maxLength: 1000, nullable: true} + Appointment: + type: object + properties: + id: {type: string, description: 内部标识,不作为展示编号} + appointmentNo: {type: string} + appointmentAt: {type: string, description: 业务日期时间,不含时区偏移} + appointmentEndAt: {type: string, nullable: true} + version: {type: integer, minimum: 0} + expectedLoadingAt: {type: string} + appointmentStatus: {type: string, enum: [PENDING, SUCCESS, FAILED, CANCELLED]} + appointmentRemark: {type: string, nullable: true} + fbaWarehouseCode: {type: string} + selectedCount: {type: integer} + createdBy: {type: string, nullable: true} + createdAt: {type: string} + updatedAt: {type: string} + items: + type: array + items: + type: object + properties: + id: {type: string} + customerId: {type: string, description: 本票货物客户归属,不在预约主单指定客户} + planId: {type: string} + lineId: {type: string} + orderId: {type: string} + inboundCode: {type: string} + fbaWarehouseCode: {type: string} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java index 1f2689cc4..14c64d874 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java @@ -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)")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java index 4884b8743..be665a139 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java @@ -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")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java index 49ebc5462..18eb8cc43 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java @@ -23,11 +23,15 @@ class InboundPlanPermissionContractTest { Map 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 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java index e77c9dd02..4e080b4b2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java @@ -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); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java index ac356d5be..a5493a0bf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java @@ -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 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; } } diff --git a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs index a660ab7e5..192bdfff6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs @@ -92,7 +92,7 @@ async function inboundAdminLifecycle(context) { const appointmentNo = `FBA-${suffix}`.slice(0, 64) const now = Date.now() const appointmentBody = { - customerId, lineIds: fbaLines.map((line) => id(line.id)), appointmentNo, + lineIds: fbaLines.map((line) => id(line.id)), appointmentNo, appointmentAt: new Date(now + 24 * 60 * 60_000).toISOString().slice(0, 19), expectedLoadingAt: new Date(now + 26 * 60 * 60_000).toISOString().slice(0, 19), appointmentStatus: 'PENDING', appointmentRemark: context.marker, @@ -108,16 +108,17 @@ async function inboundAdminLifecycle(context) { }) context.addStep(MODULE, 'FBA预约同单号异载荷返回A0409', appointmentConflict, conflict(appointmentConflict), trace(appointmentConflict), OPTIONS) - const detail = await request(context, 'GET', '查询FBA预约详情', query( - `${PLAN_BASE}/fba-appointments/${encodeURIComponent(appointmentNo)}`, { customerId })) + const detail = await request(context, 'GET', '查询FBA预约详情', + `${PLAN_BASE}/fba-appointments/${encodeURIComponent(appointmentNo)}`) context.assert(MODULE, 'FBA预约详情冻结仓码和所选明细', null, detail?.appointmentNo === appointmentNo && detail?.fbaWarehouseCode === sample.fbaWarehouseCode && detail?.items?.length === fbaLines.length, `appointmentNo=${detail?.appointmentNo || 'missing'}, warehouse=${detail?.fbaWarehouseCode || 'missing'}`, OPTIONS) const cancelled = await request(context, 'PUT', '取消本轮FBA预约收口', `${PLAN_BASE}/fba-appointments/${encodeURIComponent(appointmentNo)}`, { - customerId, appointmentAt: appointmentBody.appointmentAt, + appointmentAt: appointmentBody.appointmentAt, expectedLoadingAt: appointmentBody.expectedLoadingAt, + version: detail.version, appointmentEndAt: detail.appointmentEndAt, appointmentStatus: 'CANCELLED', appointmentRemark: `${context.marker}-cancelled`, }) context.assert(MODULE, 'FBA预约最终为CANCELLED', null,