发布已验收的跨客户同仓预约到灰度 #25
@@ -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` 仅放开散板打盘需求及两类本土订单柜型三列为空、默认空,供未提供这些事实的拼车商业订单使用;普通订单输入校验仍由原服务端请求合同执行。对应源及统一空库基线同步最终定义,不回填已生成订单或把托盘数量解释成打盘服务需求。
|
||||
|
||||
@@ -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入仓预约明细';
|
||||
@@ -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 (5107280,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,5107280,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 (5107281,0,'PLATFORM','BUTTON',5107280,'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,5107281,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));
|
||||
|
||||
@@ -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入仓预约明细';
|
||||
|
||||
@@ -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`。
|
||||
|
||||
+12
@@ -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<List<com.baiyue.firstleg.portschedule.dto.ArrivalCalendarEntry>> 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<PageResult<ScheduleView>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
@@ -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) {}
|
||||
+20
@@ -11,6 +11,26 @@ import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface PortScheduleMapper extends BaseMapper<PortScheduleEntity> {
|
||||
@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<com.baiyue.firstleg.portschedule.dto.ArrivalCalendarEntry> arrivalCalendar(
|
||||
@Param("from") java.time.LocalDateTime from, @Param("to") java.time.LocalDateTime to);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT * FROM fl_port_schedule
|
||||
|
||||
+4
@@ -35,6 +35,10 @@ public class PortScheduleRepository {
|
||||
return PageResult.from(schedules.selectPage(new Page<>(query.page(), query.size()), query));
|
||||
}
|
||||
public PortScheduleEntity find(long id) { return schedules.selectById(id); }
|
||||
public List<com.baiyue.firstleg.portschedule.dto.ArrivalCalendarEntry> arrivalCalendar(
|
||||
java.time.LocalDateTime from, java.time.LocalDateTime to) {
|
||||
return schedules.arrivalCalendar(from, to);
|
||||
}
|
||||
public PortScheduleEntity latestForDispatch(long customerId, long oceanBookingId, long containerBookingId) {
|
||||
return schedules.latestForDispatch(customerId, oceanBookingId, containerBookingId);
|
||||
}
|
||||
|
||||
+9
@@ -48,6 +48,15 @@ public class PortScheduleService {
|
||||
return detail(required(id));
|
||||
}
|
||||
|
||||
public java.util.List<com.baiyue.firstleg.portschedule.dto.ArrivalCalendarEntry> arrivalCalendar(
|
||||
java.time.LocalDate from, java.time.LocalDate to) {
|
||||
if (from == null || to == null || !to.isAfter(from)
|
||||
|| java.time.temporal.ChronoUnit.DAYS.between(from, to) > 42) {
|
||||
throw badRequest("日历查询范围必须为1至42天");
|
||||
}
|
||||
return repository.arrivalCalendar(from.atStartOfDay(), to.atStartOfDay());
|
||||
}
|
||||
|
||||
public java.util.List<PortScheduleHistoryEntity> history(long id) {
|
||||
required(id);
|
||||
return repository.histories(id);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 预约日历到港信息
|
||||
version: 1.0.0
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/platform/v1/port-schedules/arrival-calendar:
|
||||
get:
|
||||
summary: 查询日期范围内货柜到港信息
|
||||
description: '仅平台,权限platform:appointment:list。按有效柜次返回一条记录;实际到港优先取最新已观测实际船节点,其次柜次实际到港,再取预计到港。未提供日期的柜次不伪造日期。区间左闭右开,最多42天。'
|
||||
parameters:
|
||||
- {name: from, in: query, required: true, schema: {type: string, format: date}}
|
||||
- {name: to, in: query, required: true, 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:
|
||||
type: object
|
||||
properties:
|
||||
containerBookingId: {type: string}
|
||||
bookingNo: {type: string}
|
||||
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}
|
||||
arrivalType: {type: string, enum: [ACTUAL, ESTIMATED]}
|
||||
'403': {description: 无权限或非平台身份}
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.firstleg.portschedule.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ArrivalCalendarTest {
|
||||
@Test
|
||||
void rejectsInvalidRangesAndPassesTheWholeHalfOpenRangeToOneQuery() {
|
||||
var repository = mock(PortScheduleRepository.class);
|
||||
var service = new PortScheduleService();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
var from = LocalDate.of(2026, 9, 28);
|
||||
assertThrows(BizException.class, () -> service.arrivalCalendar(from, from));
|
||||
assertThrows(BizException.class, () -> service.arrivalCalendar(from, from.plusDays(43)));
|
||||
verifyNoInteractions(repository);
|
||||
when(repository.arrivalCalendar(from.atStartOfDay(), from.plusDays(42).atStartOfDay())).thenReturn(List.of());
|
||||
assertTrue(service.arrivalCalendar(from, from.plusDays(42)).isEmpty());
|
||||
verify(repository).arrivalCalendar(from.atStartOfDay(), from.plusDays(42).atStartOfDay());
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ 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, deleted)
|
||||
VALUES
|
||||
(5107280,0,'PLATFORM','MENU',0,'platform:appointment:list','预约列表','/appointment-calendar','GET',740,'ACTIVE',1,0),
|
||||
(5107281,0,'PLATFORM','BUTTON',5107280,'platform:appointment:delete','删除预约',NULL,'DELETE',741,'ACTIVE',1,0),
|
||||
(5107000,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',700,'ACTIVE',1,0),
|
||||
(5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,0),
|
||||
(5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,0),
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
| 入仓计划分页、柜级预览、转换与详情 | `inboundplan/controller/InboundPlanController.java` |
|
||||
| 自动生成托盘、资源生成失败与重试 | `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;详细规则定向查 `docs/business-index.md` 的“人工计划自动生成托盘与资源重试” |
|
||||
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
|
||||
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` |
|
||||
| 跨客户同仓预约、日历、增删改查 | `InboundPlanController.java` |
|
||||
| PDA 入仓、柜号签收、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java` → `inboundplan/pda/service/ReceivingContainerService.java`;门禁与状态定向查 `docs/business-index.md` 的“PDA 入仓、实收、上架与完成” |
|
||||
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` |
|
||||
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` |
|
||||
|
||||
+37
-9
@@ -125,20 +125,31 @@ public class InboundPlanController {
|
||||
public ApiResponse<FbaAppointmentResponse> batchFbaAppointment(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody BatchFbaAppointmentRequest request) {
|
||||
String customerId = scope(user, request.customerId());
|
||||
requireAppointmentPlatform(user);
|
||||
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
return ApiResponse.success(appointmentService.batchCreate(customerId, operatorId, request));
|
||||
return ApiResponse.success(appointmentService.batchCreate(operatorId, request));
|
||||
}
|
||||
|
||||
@GetMapping("/fba-appointments/calendar")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.APPOINTMENT_LIST + "')")
|
||||
@Operation(summary = "按日期范围查询 FBA 预约日历", description = "Required authority: " + OverseasPermissions.APPOINTMENT_LIST)
|
||||
public ApiResponse<java.util.List<FbaAppointmentResponse>> fbaAppointmentCalendar(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate from,
|
||||
@RequestParam @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate to) {
|
||||
requireAppointmentPlatform(user);
|
||||
return ApiResponse.success(appointmentService.calendar(from, to));
|
||||
}
|
||||
|
||||
@GetMapping("/fba-appointments/{appointmentNo}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
|
||||
@PreAuthorize("hasAnyAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "','" + OverseasPermissions.APPOINTMENT_LIST + "')")
|
||||
/** 查询 FBA 预约及其选中的入仓明细。 */
|
||||
@Operation(summary = "查询 FBA 预约详情", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
|
||||
@Operation(summary = "查询 FBA 预约详情", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST + " or " + OverseasPermissions.APPOINTMENT_LIST)
|
||||
public ApiResponse<FbaAppointmentResponse> fbaAppointmentDetail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String appointmentNo,
|
||||
@RequestParam(required = false) String customerId) {
|
||||
return ApiResponse.success(appointmentService.detail(scope(user, customerId), appointmentNo));
|
||||
@PathVariable String appointmentNo) {
|
||||
requireAppointmentPlatform(user);
|
||||
return ApiResponse.success(appointmentService.detail(appointmentNo));
|
||||
}
|
||||
|
||||
@PutMapping("/fba-appointments/{appointmentNo}")
|
||||
@@ -148,8 +159,25 @@ public class InboundPlanController {
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String appointmentNo,
|
||||
@Valid @RequestBody UpdateFbaAppointmentRequest request) {
|
||||
return ApiResponse.success(appointmentService.update(scope(user, request.customerId()),
|
||||
appointmentNo, request));
|
||||
requireAppointmentPlatform(user);
|
||||
return ApiResponse.success(appointmentService.update(appointmentNo, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/fba-appointments/{appointmentNo}")
|
||||
@PreAuthorize("hasAuthority('platform:appointment:delete')")
|
||||
@Operation(summary = "删除未关联排车的预约", description = "Required authority: platform:appointment:delete")
|
||||
public ApiResponse<Void> deleteFbaAppointment(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String appointmentNo, @RequestParam int version) {
|
||||
requireAppointmentPlatform(user);
|
||||
appointmentService.delete(appointmentNo, version);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
private void requireAppointmentPlatform(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "预约由平台统一管理");
|
||||
}
|
||||
}
|
||||
|
||||
private String scope(AuthenticatedUser user, String requestedCustomer) {
|
||||
|
||||
+2
-2
@@ -9,12 +9,12 @@ import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public record BatchFbaAppointmentRequest(
|
||||
String customerId,
|
||||
@NotEmpty @Size(max = 500) List<@NotNull Long> lineIds,
|
||||
@NotBlank @Size(max = 64) String appointmentNo,
|
||||
@NotNull LocalDateTime appointmentAt,
|
||||
@NotNull LocalDateTime expectedLoadingAt,
|
||||
@NotBlank @Size(max = 32) String appointmentStatus,
|
||||
@Size(max = 1000) String appointmentRemark
|
||||
@Size(max = 1000) String appointmentRemark,
|
||||
LocalDateTime appointmentEndAt
|
||||
) {
|
||||
}
|
||||
|
||||
+4
-3
@@ -5,7 +5,6 @@ import java.util.List;
|
||||
|
||||
public record FbaAppointmentResponse(
|
||||
Long id,
|
||||
String customerId,
|
||||
String appointmentNo,
|
||||
LocalDateTime appointmentAt,
|
||||
LocalDateTime expectedLoadingAt,
|
||||
@@ -16,9 +15,11 @@ public record FbaAppointmentResponse(
|
||||
String createdBy,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt,
|
||||
List<Item> items
|
||||
List<Item> items,
|
||||
LocalDateTime appointmentEndAt,
|
||||
Integer version
|
||||
) {
|
||||
public record Item(Long id, Long planId, Long lineId, Long orderId, String inboundCode,
|
||||
String fbaWarehouseCode) {
|
||||
String fbaWarehouseCode, String customerId) {
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -7,10 +7,11 @@ import jakarta.validation.constraints.Size;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record UpdateFbaAppointmentRequest(
|
||||
String customerId,
|
||||
@NotNull LocalDateTime appointmentAt,
|
||||
@NotNull LocalDateTime expectedLoadingAt,
|
||||
@NotBlank @Size(max = 32) String appointmentStatus,
|
||||
@Size(max = 1000) String appointmentRemark
|
||||
@Size(max = 1000) String appointmentRemark,
|
||||
LocalDateTime appointmentEndAt,
|
||||
@NotNull @jakarta.validation.constraints.Min(0) Integer version
|
||||
) {
|
||||
}
|
||||
|
||||
+4
-1
@@ -12,11 +12,14 @@ import java.time.LocalDateTime;
|
||||
public class InboundFbaAppointment {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private String customerId;
|
||||
private String appointmentNo;
|
||||
private LocalDateTime appointmentAt;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private LocalDateTime appointmentEndAt;
|
||||
private Integer version;
|
||||
private LocalDateTime expectedLoadingAt;
|
||||
private String appointmentStatus;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private String appointmentRemark;
|
||||
private String fbaWarehouseCode;
|
||||
private Integer selectedCount;
|
||||
|
||||
+1
@@ -13,6 +13,7 @@ public class InboundFbaAppointmentItem {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private Long appointmentId;
|
||||
private String customerId;
|
||||
private Long planId;
|
||||
private Long lineId;
|
||||
private Long orderId;
|
||||
|
||||
+18
-4
@@ -11,12 +11,26 @@ import java.util.List;
|
||||
public interface InboundFbaAppointmentQueryMapper {
|
||||
@Select("<script>" +
|
||||
"SELECT l.id AS lineId,l.plan_id AS planId,l.order_id AS orderId,l.inbound_code AS inboundCode," +
|
||||
"l.fba_warehouse_code AS fbaWarehouseCode " +
|
||||
"l.fba_warehouse_code AS fbaWarehouseCode,p.customer_id AS customerId " +
|
||||
"FROM ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id " +
|
||||
"WHERE p.customer_id=#{customerId} AND l.id IN " +
|
||||
"WHERE l.id IN " +
|
||||
"<foreach collection='lineIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||
"ORDER BY l.id FOR UPDATE" +
|
||||
"</script>")
|
||||
List<FbaAppointmentLineSnapshot> lockLines(@Param("customerId") String customerId,
|
||||
@Param("lineIds") List<Long> lineIds);
|
||||
List<FbaAppointmentLineSnapshot> lockLines(@Param("lineIds") List<Long> lineIds);
|
||||
|
||||
@Select("<script>SELECT COUNT(*) FROM ll_inbound_fba_appointment_item i " +
|
||||
"JOIN ll_inbound_fba_appointment a ON a.id=i.appointment_id " +
|
||||
"WHERE a.appointment_status NOT IN ('CANCELLED','FAILED') " +
|
||||
"<if test='excludedId != null'>AND a.id<>#{excludedId} </if>AND i.line_id IN " +
|
||||
"<foreach collection='lineIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
|
||||
long countActiveAppointments(@Param("lineIds") List<Long> 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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ public interface InboundPlanMapper extends BaseMapper<InboundPlan> {
|
||||
"<if test='q.fbaWarehouseCode != null and q.fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{q.fbaWarehouseCode}</if>" +
|
||||
"<if test='q.appointmentStatus != null and q.appointmentStatus != \"\" and q.appointmentStatus != \"NOT_APPOINTED\"'> " +
|
||||
"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})</if>" +
|
||||
"WHERE ai.plan_id=p.id AND ai.customer_id=#{customerId} AND a.appointment_status=#{q.appointmentStatus})</if>" +
|
||||
"<if test='q.appointmentStatus == \"NOT_APPOINTED\"'> AND NOT EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai " +
|
||||
"WHERE ai.plan_id=p.id)</if>" +
|
||||
"<if test='q.createdFrom != null'> AND p.created_at >=#{q.createdFrom}</if>" +
|
||||
|
||||
+3
-3
@@ -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" +
|
||||
"<if test='customerId != null and customerId != \"\"'> AND p.customer_id=#{customerId} AND o.customer_id=#{customerId}</if>" +
|
||||
" 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 <if test='customerId != null and customerId != \"\"'> AND a2.customer_id=#{customerId}</if>" +
|
||||
" 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 <if test='customerId != null and customerId != \"\"'> AND a.customer_id=#{customerId}</if>" +
|
||||
" LEFT JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id" +
|
||||
" WHERE 1=1" +
|
||||
"<if test='q.planNo != null and q.planNo != \"\"'> AND p.plan_no LIKE CONCAT('%',#{q.planNo},'%')</if>" +
|
||||
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ public record FbaAppointmentLineSnapshot(
|
||||
Long planId,
|
||||
Long orderId,
|
||||
String inboundCode,
|
||||
String fbaWarehouseCode
|
||||
String fbaWarehouseCode,
|
||||
String customerId
|
||||
) {
|
||||
}
|
||||
|
||||
+97
-25
@@ -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<FbaAppointmentResponse> 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<InboundFbaAppointment> appointments = appointmentMapper.selectList(
|
||||
new LambdaQueryWrapper<InboundFbaAppointment>()
|
||||
.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<InboundFbaAppointmentItem>()
|
||||
.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<Long> 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<FbaAppointmentLineSnapshot> lines = queryMapper.lockLines(customerId, lineIds);
|
||||
List<FbaAppointmentLineSnapshot> 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<Long> 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<InboundFbaAppointment>()
|
||||
.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<Long> 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<InboundFbaAppointment>()
|
||||
.eq(InboundFbaAppointment::getId, appointment.getId())
|
||||
.eq(InboundFbaAppointment::getVersion, version)) != 1) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "预约已被其他人修改,请刷新后重试");
|
||||
}
|
||||
itemMapper.delete(new LambdaQueryWrapper<InboundFbaAppointmentItem>()
|
||||
.eq(InboundFbaAppointmentItem::getAppointmentId, appointment.getId()));
|
||||
}
|
||||
|
||||
private FbaAppointmentResponse verifyReplay(InboundFbaAppointment existing, List<Long> requestedLineIds,
|
||||
LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt,
|
||||
String status, String remark) {
|
||||
String status, String remark, LocalDateTime appointmentEndAt) {
|
||||
List<InboundFbaAppointmentItem> existingItems = items(existing.getId());
|
||||
Set<Long> 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<InboundFbaAppointment> wrapper = new LambdaQueryWrapper<InboundFbaAppointment>()
|
||||
.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<InboundFbaAppointmentItem> 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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
+5
-3
@@ -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)"));
|
||||
|
||||
+15
-4
@@ -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"));
|
||||
|
||||
+5
-1
@@ -23,11 +23,15 @@ class InboundPlanPermissionContractTest {
|
||||
Map<String, Method> methods = Arrays.stream(InboundPlanController.class.getDeclaredMethods())
|
||||
.collect(Collectors.toMap(Method::getName, method -> method));
|
||||
|
||||
for (String name : new String[] {"page", "detail", "fbaAppointmentDetail"}) {
|
||||
for (String name : new String[] {"page", "detail"}) {
|
||||
Method method = methods.get(name);
|
||||
assertEquals("hasAuthority('" + LIST + "')", method.getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals("Required authority: " + LIST, method.getAnnotation(Operation.class).description());
|
||||
}
|
||||
assertEquals("hasAnyAuthority('" + LIST + "','platform:appointment:list')",
|
||||
methods.get("fbaAppointmentDetail").getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals("hasAuthority('platform:appointment:list')",
|
||||
methods.get("fbaAppointmentCalendar").getAnnotation(PreAuthorize.class).value());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+19
-2
@@ -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);
|
||||
|
||||
+124
-23
@@ -19,6 +19,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@@ -46,49 +47,54 @@ class InboundFbaAppointmentServiceTest {
|
||||
ReflectionTestUtils.setField(service, "itemMapper", itemMapper);
|
||||
ReflectionTestUtils.setField(service, "queryMapper", queryMapper);
|
||||
lenient().when(itemMapper.selectList(any())).thenReturn(List.of());
|
||||
lenient().when(appointmentMapper.update(any(InboundFbaAppointment.class), any())).thenReturn(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsOneBatchForSelectedLinesAndFreezesFbaWarehouse() throws Exception {
|
||||
assertEquals(true, InboundFbaAppointmentService.class
|
||||
.getMethod("batchCreate", String.class, String.class, BatchFbaAppointmentRequest.class)
|
||||
.getMethod("batchCreate", String.class, BatchFbaAppointmentRequest.class)
|
||||
.getAnnotation(Transactional.class) != null);
|
||||
when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(
|
||||
when(queryMapper.lockLines(List.of(11L, 12L))).thenReturn(List.of(
|
||||
line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "ONT8")));
|
||||
|
||||
var result = service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1"));
|
||||
var result = service.batchCreate("7", request(List.of(11L, 12L), "APT-1"));
|
||||
|
||||
assertEquals("APT-1", result.appointmentNo());
|
||||
assertEquals("ONT8", result.fbaWarehouseCode());
|
||||
assertEquals(expectedLoadingAt, result.expectedLoadingAt());
|
||||
assertEquals(2, result.selectedCount());
|
||||
var captured = org.mockito.ArgumentCaptor.forClass(InboundFbaAppointmentItem.class);
|
||||
verify(itemMapper, org.mockito.Mockito.times(2)).insert(captured.capture());
|
||||
assertEquals(List.of("CUSTOMER-11", "CUSTOMER-12"), captured.getAllValues().stream()
|
||||
.map(InboundFbaAppointmentItem::getCustomerId).toList());
|
||||
verify(appointmentMapper).insert(any(InboundFbaAppointment.class));
|
||||
verify(itemMapper, org.mockito.Mockito.times(2)).insert(any(InboundFbaAppointmentItem.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMixedFbaWarehouses() {
|
||||
when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(
|
||||
when(queryMapper.lockLines(List.of(11L, 12L))).thenReturn(List.of(
|
||||
line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "LAX9")));
|
||||
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1")));
|
||||
() -> service.batchCreate("7", request(List.of(11L, 12L), "APT-1")));
|
||||
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingLineFromAnotherTenant() {
|
||||
when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(line(11L, "IN-11", "ONT8")));
|
||||
void rejectsMissingSelectedLine() {
|
||||
when(queryMapper.lockLines(List.of(11L, 12L))).thenReturn(List.of(line(11L, "IN-11", "ONT8")));
|
||||
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1")));
|
||||
() -> service.batchCreate("7", request(List.of(11L, 12L), "APT-1")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateLineIdsAreRejectedBeforeDatabaseAccess() {
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("100", "7", request(List.of(11L, 11L), "APT-1")));
|
||||
verify(queryMapper, never()).lockLines(any(), any());
|
||||
() -> service.batchCreate("7", request(List.of(11L, 11L), "APT-1")));
|
||||
verify(queryMapper, never()).lockLines(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -101,7 +107,7 @@ class InboundFbaAppointmentServiceTest {
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(existing);
|
||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||
|
||||
var result = service.batchCreate("100", "7", request(List.of(11L), "APT-1"));
|
||||
var result = service.batchCreate("7", request(List.of(11L), "APT-1"));
|
||||
|
||||
assertEquals(99L, result.id());
|
||||
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
|
||||
@@ -115,7 +121,7 @@ class InboundFbaAppointmentServiceTest {
|
||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("100", "7", request(List.of(12L), "APT-1")));
|
||||
() -> service.batchCreate("7", request(List.of(12L), "APT-1")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -124,17 +130,17 @@ class InboundFbaAppointmentServiceTest {
|
||||
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
||||
item.setLineId(11L);
|
||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||
BatchFbaAppointmentRequest changed = new BatchFbaAppointmentRequest("100", List.of(11L), "APT-1",
|
||||
appointmentAt, expectedLoadingAt.plusHours(1), "SUCCESS", "dock");
|
||||
BatchFbaAppointmentRequest changed = new BatchFbaAppointmentRequest(List.of(11L), "APT-1",
|
||||
appointmentAt, expectedLoadingAt.plusHours(1), "SUCCESS", "dock", null);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("100", "7", changed));
|
||||
() -> service.batchCreate("7", changed));
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stringSnowflakeLineIdsDeserializeWithoutPrecisionLoss() throws Exception {
|
||||
String json = "{\"customerId\":\"100\",\"lineIds\":[\"9007199254740993\"],"
|
||||
String json = "{\"lineIds\":[\"9007199254740993\"],"
|
||||
+ "\"appointmentNo\":\"APT-1\",\"appointmentAt\":\"2026-08-20T10:30:00\","
|
||||
+ "\"expectedLoadingAt\":\"2026-08-21T08:00:00\",\"appointmentStatus\":\"PENDING\"}";
|
||||
|
||||
@@ -151,28 +157,122 @@ class InboundFbaAppointmentServiceTest {
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(existing);
|
||||
LocalDateTime loadingAt = expectedLoadingAt.plusHours(2);
|
||||
|
||||
var result = service.update("100", "APT-1", new UpdateFbaAppointmentRequest(
|
||||
"100", appointmentAt.plusDays(1), loadingAt, "PENDING", "updated"));
|
||||
var result = service.update("APT-1", new UpdateFbaAppointmentRequest(
|
||||
appointmentAt.plusDays(1), loadingAt, "PENDING", "updated", null, 0));
|
||||
|
||||
assertEquals(loadingAt, result.expectedLoadingAt());
|
||||
assertEquals("PENDING", result.appointmentStatus());
|
||||
verify(appointmentMapper).updateById(existing);
|
||||
verify(appointmentMapper).update(org.mockito.ArgumentMatchers.eq(existing), any());
|
||||
verify(itemMapper, never()).insert(any(InboundFbaAppointmentItem.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preventsAssigningTheSameCargoToTwoActiveAppointments() {
|
||||
when(queryMapper.lockLines(List.of(11L))).thenReturn(List.of(line(11L, "IN-11", "ONT8")));
|
||||
when(queryMapper.countActiveAppointments(List.of(11L), null)).thenReturn(1L);
|
||||
var exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("7", request(List.of(11L), "SECOND-APT")));
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelledAppointmentCannotBeReactivatedAfterItsCargoWasBookedElsewhere() {
|
||||
InboundFbaAppointment existing = appointment(99L, "APT-1", "ONT8");
|
||||
existing.setAppointmentStatus("CANCELLED");
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(existing);
|
||||
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
||||
item.setLineId(11L);
|
||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||
when(queryMapper.countActiveAppointments(List.of(11L), 99L)).thenReturn(1L);
|
||||
assertThrows(ResponseStatusException.class, () -> service.update("APT-1",
|
||||
new UpdateFbaAppointmentRequest(appointmentAt, expectedLoadingAt, "SUCCESS", "", null, 0)));
|
||||
verify(queryMapper).lockLines(List.of(11L));
|
||||
verify(appointmentMapper, never()).update(any(InboundFbaAppointment.class), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnAppointmentWindowWhoseEndIsNotAfterItsStart() {
|
||||
var request = new BatchFbaAppointmentRequest(List.of(11L), "BAD-WINDOW", appointmentAt,
|
||||
expectedLoadingAt, "PENDING", "", appointmentAt.minusMinutes(1));
|
||||
assertThrows(ResponseStatusException.class, () -> service.batchCreate("7", request));
|
||||
verify(queryMapper, never()).lockLines(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsStaleEditBeforeWritingTheSharedAppointment() {
|
||||
var existing = appointment(99L, "APT-1", "ONT8");
|
||||
existing.setVersion(3);
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(existing);
|
||||
var request = new UpdateFbaAppointmentRequest(appointmentAt, expectedLoadingAt,
|
||||
"PENDING", "", appointmentAt.plusHours(2), 2);
|
||||
var error = assertThrows(ResponseStatusException.class, () -> service.update("APT-1", request));
|
||||
assertEquals(409, error.getStatusCode().value());
|
||||
verify(appointmentMapper, never()).update(any(InboundFbaAppointment.class), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cannotDeleteAnAppointmentWhoseGoodsAlreadyHaveAnActiveDispatch() {
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(appointment(99L, "APT-1", "ONT8"));
|
||||
when(queryMapper.countActiveDispatches(99L)).thenReturn(1L);
|
||||
assertThrows(ResponseStatusException.class, () -> service.delete("APT-1", 0));
|
||||
verify(appointmentMapper, never()).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
|
||||
verify(itemMapper, never()).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleDeleteKeepsAllCargoAssociations() {
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(appointment(99L, "APT-1", "ONT8"));
|
||||
when(appointmentMapper.delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(0);
|
||||
assertThrows(ResponseStatusException.class, () -> service.delete("APT-1", 0));
|
||||
verify(itemMapper, never()).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
|
||||
}
|
||||
|
||||
private BatchFbaAppointmentRequest request(List<Long> lineIds, String number) {
|
||||
return new BatchFbaAppointmentRequest("100", lineIds, number, appointmentAt, expectedLoadingAt,
|
||||
"SUCCESS", "dock");
|
||||
return new BatchFbaAppointmentRequest(lineIds, number, appointmentAt, expectedLoadingAt,
|
||||
"SUCCESS", "dock", null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void calendarRejectsInvalidOrUnboundedDateRangesBeforeQuerying() {
|
||||
LocalDate from = LocalDate.of(2026, 9, 1);
|
||||
for (LocalDate to : List.of(from, from.minusDays(1), from.plusDays(43))) {
|
||||
assertThrows(ResponseStatusException.class, () -> service.calendar(from, to));
|
||||
}
|
||||
verify(appointmentMapper, never()).selectList(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyCalendarDoesNotQueryAppointmentItems() {
|
||||
when(appointmentMapper.selectList(any())).thenReturn(List.of());
|
||||
assertEquals(List.of(), service.calendar(LocalDate.of(2026, 9, 1), LocalDate.of(2026, 10, 1)));
|
||||
verify(itemMapper, never()).selectList(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void calendarLoadsAllItemsInOneBatchAndKeepsAppointmentAssociation() {
|
||||
var first = appointment(99L, "APT-1", "ONT8");
|
||||
var second = appointment(100L, "APT-2", "LAX9");
|
||||
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
||||
item.setAppointmentId(100L);
|
||||
item.setLineId(11L);
|
||||
item.setInboundCode("IN-11");
|
||||
when(appointmentMapper.selectList(any())).thenReturn(List.of(first, second));
|
||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||
var result = service.calendar(LocalDate.of(2026, 9, 1), LocalDate.of(2026, 10, 1));
|
||||
assertEquals(2, result.size());
|
||||
assertEquals(0, result.get(0).items().size());
|
||||
assertEquals("IN-11", result.get(1).items().get(0).inboundCode());
|
||||
verify(itemMapper).selectList(any());
|
||||
}
|
||||
|
||||
private FbaAppointmentLineSnapshot line(Long id, String inboundCode, String fba) {
|
||||
return new FbaAppointmentLineSnapshot(id, 1000L, id + 100L, inboundCode, fba);
|
||||
return new FbaAppointmentLineSnapshot(id, 1000L, id + 100L, inboundCode, fba, "CUSTOMER-" + id);
|
||||
}
|
||||
|
||||
private InboundFbaAppointment appointment(Long id, String number, String fba) {
|
||||
InboundFbaAppointment value = new InboundFbaAppointment();
|
||||
value.setId(id);
|
||||
value.setCustomerId("100");
|
||||
value.setAppointmentNo(number);
|
||||
value.setAppointmentAt(appointmentAt);
|
||||
value.setExpectedLoadingAt(expectedLoadingAt);
|
||||
@@ -180,6 +280,7 @@ class InboundFbaAppointmentServiceTest {
|
||||
value.setAppointmentRemark("dock");
|
||||
value.setFbaWarehouseCode(fba);
|
||||
value.setSelectedCount(1);
|
||||
value.setVersion(0);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -81,7 +81,9 @@ class InboundPlanPaginationTest {
|
||||
String sql = String.join("", InboundPlanQueryMapper.class
|
||||
.getMethod("rows", String.class, InboundPlanRequests.Query.class, long.class, long.class)
|
||||
.getAnnotation(org.apache.ibatis.annotations.Select.class).value()) + InboundPlanQueryMapper.FROM_AND_FILTER;
|
||||
assertThat(sql).contains("a2.customer_id=#{customerId}", "ORDER BY a2.created_at DESC,ai2.id DESC LIMIT 1");
|
||||
assertThat(sql).contains("ai2.customer_id=p.customer_id", "ORDER BY a2.created_at DESC,ai2.id DESC LIMIT 1");
|
||||
assertThat(sql).contains("p.customer_id=#{customerId}", "o.customer_id=p.customer_id");
|
||||
assertThat(sql).doesNotContain("a2.customer_id=#{customerId}");
|
||||
assertThat(sql).contains("di.customer_id=p.customer_id AND di.order_id=l.order_id",
|
||||
"dispatch.customer_id=di.customer_id", "dispatch.status<>'CANCELLED'");
|
||||
assertThat(sql).contains("p.overseas_warehouse_id overseasWarehouseId", "o.overseas_warehouse_code overseasWarehouseCode",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user