发布已验收的跨客户同仓预约到灰度 #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/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` 仅放开散板打盘需求及两类本土订单柜型三列为空、默认空,供未提供这些事实的拼车商业订单使用;普通订单输入校验仍由原服务端请求合同执行。对应源及统一空库基线同步最终定义,不回填已生成订单或把托盘数量解释成打盘服务需求。
|
`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,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,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_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,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,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));
|
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 (
|
CREATE TABLE ll_inbound_fba_appointment (
|
||||||
id bigint NOT NULL COMMENT '主键ID',
|
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_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '预约单号',
|
||||||
appointment_at datetime(3) 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 '预计装车时间',
|
expected_loading_at datetime(3) NOT NULL COMMENT '预计装车时间',
|
||||||
appointment_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' 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 '预约备注',
|
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 '创建时间',
|
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 '更新时间',
|
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_ll_fba_appointment_no (customer_id,appointment_no),
|
UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
|
||||||
KEY idx_ll_fba_appointment_list (customer_id,appointment_status,fba_warehouse_code,created_at)
|
KEY idx_ll_fba_appointment_list (appointment_at,appointment_status,fba_warehouse_code)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA入仓预约单';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA入仓预约单';
|
||||||
|
|
||||||
CREATE TABLE ll_inbound_fba_appointment_item (
|
CREATE TABLE ll_inbound_fba_appointment_item (
|
||||||
id bigint NOT NULL COMMENT '主键ID',
|
id bigint NOT NULL COMMENT '主键ID',
|
||||||
appointment_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',
|
plan_id bigint NOT NULL COMMENT '入仓计划ID',
|
||||||
line_id bigint NOT NULL COMMENT '入仓计划明细ID',
|
line_id bigint NOT NULL COMMENT '入仓计划明细ID',
|
||||||
order_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 '创建时间',
|
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_ll_fba_appointment_item_line (appointment_id,line_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_plan (plan_id),
|
||||||
KEY idx_ll_fba_appointment_item_order (order_id)
|
KEY idx_ll_fba_appointment_item_order (order_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA入仓预约明细';
|
) 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/`、`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`。
|
事件人工重试:`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 {
|
public class PlatformPortScheduleController {
|
||||||
@Resource private PortScheduleService service;
|
@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
|
@GetMapping
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule:list')")
|
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule:list')")
|
||||||
public ApiResponse<PageResult<ScheduleView>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
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
|
@Mapper
|
||||||
public interface PortScheduleMapper extends BaseMapper<PortScheduleEntity> {
|
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("""
|
@Select("""
|
||||||
<script>
|
<script>
|
||||||
SELECT * FROM fl_port_schedule
|
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));
|
return PageResult.from(schedules.selectPage(new Page<>(query.page(), query.size()), query));
|
||||||
}
|
}
|
||||||
public PortScheduleEntity find(long id) { return schedules.selectById(id); }
|
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) {
|
public PortScheduleEntity latestForDispatch(long customerId, long oceanBookingId, long containerBookingId) {
|
||||||
return schedules.latestForDispatch(customerId, oceanBookingId, containerBookingId);
|
return schedules.latestForDispatch(customerId, oceanBookingId, containerBookingId);
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -48,6 +48,15 @@ public class PortScheduleService {
|
|||||||
return detail(required(id));
|
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) {
|
public java.util.List<PortScheduleHistoryEntity> history(long id) {
|
||||||
required(id);
|
required(id);
|
||||||
return repository.histories(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,
|
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
|
||||||
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
|
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
|
||||||
VALUES
|
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),
|
(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),
|
(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),
|
(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/controller/InboundPlanController.java` |
|
||||||
| 自动生成托盘、资源生成失败与重试 | `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;详细规则定向查 `docs/business-index.md` 的“人工计划自动生成托盘与资源重试” |
|
| 自动生成托盘、资源生成失败与重试 | `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;详细规则定向查 `docs/business-index.md` 的“人工计划自动生成托盘与资源重试” |
|
||||||
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
|
| 托盘分配算法 | `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 入仓、柜号签收、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java` → `inboundplan/pda/service/ReceivingContainerService.java`;门禁与状态定向查 `docs/business-index.md` 的“PDA 入仓、实收、上架与完成” |
|
||||||
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` |
|
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` |
|
||||||
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` |
|
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` |
|
||||||
|
|||||||
+37
-9
@@ -125,20 +125,31 @@ public class InboundPlanController {
|
|||||||
public ApiResponse<FbaAppointmentResponse> batchFbaAppointment(
|
public ApiResponse<FbaAppointmentResponse> batchFbaAppointment(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody BatchFbaAppointmentRequest request) {
|
@Valid @RequestBody BatchFbaAppointmentRequest request) {
|
||||||
String customerId = scope(user, request.customerId());
|
requireAppointmentPlatform(user);
|
||||||
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
|
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}")
|
@GetMapping("/fba-appointments/{appointmentNo}")
|
||||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
|
@PreAuthorize("hasAnyAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "','" + OverseasPermissions.APPOINTMENT_LIST + "')")
|
||||||
/** 查询 FBA 预约及其选中的入仓明细。 */
|
/** 查询 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(
|
public ApiResponse<FbaAppointmentResponse> fbaAppointmentDetail(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable String appointmentNo,
|
@PathVariable String appointmentNo) {
|
||||||
@RequestParam(required = false) String customerId) {
|
requireAppointmentPlatform(user);
|
||||||
return ApiResponse.success(appointmentService.detail(scope(user, customerId), appointmentNo));
|
return ApiResponse.success(appointmentService.detail(appointmentNo));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/fba-appointments/{appointmentNo}")
|
@PutMapping("/fba-appointments/{appointmentNo}")
|
||||||
@@ -148,8 +159,25 @@ public class InboundPlanController {
|
|||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable String appointmentNo,
|
@PathVariable String appointmentNo,
|
||||||
@Valid @RequestBody UpdateFbaAppointmentRequest request) {
|
@Valid @RequestBody UpdateFbaAppointmentRequest request) {
|
||||||
return ApiResponse.success(appointmentService.update(scope(user, request.customerId()),
|
requireAppointmentPlatform(user);
|
||||||
appointmentNo, request));
|
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) {
|
private String scope(AuthenticatedUser user, String requestedCustomer) {
|
||||||
|
|||||||
+2
-2
@@ -9,12 +9,12 @@ import java.time.LocalDateTime;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public record BatchFbaAppointmentRequest(
|
public record BatchFbaAppointmentRequest(
|
||||||
String customerId,
|
|
||||||
@NotEmpty @Size(max = 500) List<@NotNull Long> lineIds,
|
@NotEmpty @Size(max = 500) List<@NotNull Long> lineIds,
|
||||||
@NotBlank @Size(max = 64) String appointmentNo,
|
@NotBlank @Size(max = 64) String appointmentNo,
|
||||||
@NotNull LocalDateTime appointmentAt,
|
@NotNull LocalDateTime appointmentAt,
|
||||||
@NotNull LocalDateTime expectedLoadingAt,
|
@NotNull LocalDateTime expectedLoadingAt,
|
||||||
@NotBlank @Size(max = 32) String appointmentStatus,
|
@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(
|
public record FbaAppointmentResponse(
|
||||||
Long id,
|
Long id,
|
||||||
String customerId,
|
|
||||||
String appointmentNo,
|
String appointmentNo,
|
||||||
LocalDateTime appointmentAt,
|
LocalDateTime appointmentAt,
|
||||||
LocalDateTime expectedLoadingAt,
|
LocalDateTime expectedLoadingAt,
|
||||||
@@ -16,9 +15,11 @@ public record FbaAppointmentResponse(
|
|||||||
String createdBy,
|
String createdBy,
|
||||||
LocalDateTime createdAt,
|
LocalDateTime createdAt,
|
||||||
LocalDateTime updatedAt,
|
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,
|
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;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
public record UpdateFbaAppointmentRequest(
|
public record UpdateFbaAppointmentRequest(
|
||||||
String customerId,
|
|
||||||
@NotNull LocalDateTime appointmentAt,
|
@NotNull LocalDateTime appointmentAt,
|
||||||
@NotNull LocalDateTime expectedLoadingAt,
|
@NotNull LocalDateTime expectedLoadingAt,
|
||||||
@NotBlank @Size(max = 32) String appointmentStatus,
|
@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 {
|
public class InboundFbaAppointment {
|
||||||
@TableId(type = IdType.INPUT)
|
@TableId(type = IdType.INPUT)
|
||||||
private Long id;
|
private Long id;
|
||||||
private String customerId;
|
|
||||||
private String appointmentNo;
|
private String appointmentNo;
|
||||||
private LocalDateTime appointmentAt;
|
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 LocalDateTime expectedLoadingAt;
|
||||||
private String appointmentStatus;
|
private String appointmentStatus;
|
||||||
|
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||||
private String appointmentRemark;
|
private String appointmentRemark;
|
||||||
private String fbaWarehouseCode;
|
private String fbaWarehouseCode;
|
||||||
private Integer selectedCount;
|
private Integer selectedCount;
|
||||||
|
|||||||
+1
@@ -13,6 +13,7 @@ public class InboundFbaAppointmentItem {
|
|||||||
@TableId(type = IdType.INPUT)
|
@TableId(type = IdType.INPUT)
|
||||||
private Long id;
|
private Long id;
|
||||||
private Long appointmentId;
|
private Long appointmentId;
|
||||||
|
private String customerId;
|
||||||
private Long planId;
|
private Long planId;
|
||||||
private Long lineId;
|
private Long lineId;
|
||||||
private Long orderId;
|
private Long orderId;
|
||||||
|
|||||||
+18
-4
@@ -11,12 +11,26 @@ import java.util.List;
|
|||||||
public interface InboundFbaAppointmentQueryMapper {
|
public interface InboundFbaAppointmentQueryMapper {
|
||||||
@Select("<script>" +
|
@Select("<script>" +
|
||||||
"SELECT l.id AS lineId,l.plan_id AS planId,l.order_id AS orderId,l.inbound_code AS inboundCode," +
|
"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 " +
|
"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> " +
|
"<foreach collection='lineIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||||
"ORDER BY l.id FOR UPDATE" +
|
"ORDER BY l.id FOR UPDATE" +
|
||||||
"</script>")
|
"</script>")
|
||||||
List<FbaAppointmentLineSnapshot> lockLines(@Param("customerId") String customerId,
|
List<FbaAppointmentLineSnapshot> lockLines(@Param("lineIds") List<Long> lineIds);
|
||||||
@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.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\"'> " +
|
"<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 " +
|
"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 " +
|
"<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>" +
|
"WHERE ai.plan_id=p.id)</if>" +
|
||||||
"<if test='q.createdFrom != null'> AND p.created_at >=#{q.createdFrom}</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" +
|
String FROM_AND_FILTER = " FROM ll_inbound_plan_line l" +
|
||||||
" JOIN ll_inbound_plan p ON p.id=l.plan_id" +
|
" 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>" +
|
"<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" +
|
" 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" +
|
" 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" +
|
" 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)" +
|
" 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" +
|
" WHERE 1=1" +
|
||||||
"<if test='q.planNo != null and q.planNo != \"\"'> AND p.plan_no LIKE CONCAT('%',#{q.planNo},'%')</if>" +
|
"<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>" +
|
"<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 planId,
|
||||||
Long orderId,
|
Long orderId,
|
||||||
String inboundCode,
|
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 org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
@@ -32,8 +34,29 @@ public class InboundFbaAppointmentService {
|
|||||||
@Resource private InboundFbaAppointmentItemMapper itemMapper;
|
@Resource private InboundFbaAppointmentItemMapper itemMapper;
|
||||||
@Resource private InboundFbaAppointmentQueryMapper queryMapper;
|
@Resource private InboundFbaAppointmentQueryMapper queryMapper;
|
||||||
|
|
||||||
@Transactional
|
/** A bounded calendar read uses two batch queries, never one detail query per appointment. */
|
||||||
public FbaAppointmentResponse batchCreate(String customerId, String operatorId,
|
@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) {
|
BatchFbaAppointmentRequest request) {
|
||||||
List<Long> lineIds = List.copyOf(request.lineIds());
|
List<Long> lineIds = List.copyOf(request.lineIds());
|
||||||
if (new HashSet<>(lineIds).size() != lineIds.size()) {
|
if (new HashSet<>(lineIds).size() != lineIds.size()) {
|
||||||
@@ -42,31 +65,37 @@ public class InboundFbaAppointmentService {
|
|||||||
String appointmentNo = requiredTrimmed(request.appointmentNo(), "Appointment number is required");
|
String appointmentNo = requiredTrimmed(request.appointmentNo(), "Appointment number is required");
|
||||||
String status = normalizeStatus(request.appointmentStatus());
|
String status = normalizeStatus(request.appointmentStatus());
|
||||||
String remark = trimToNull(request.appointmentRemark());
|
String remark = trimToNull(request.appointmentRemark());
|
||||||
|
validateWindow(request.appointmentAt(), request.appointmentEndAt());
|
||||||
|
|
||||||
InboundFbaAppointment existing = find(customerId, appointmentNo);
|
InboundFbaAppointment existing = find(appointmentNo);
|
||||||
if (existing != null) {
|
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()) {
|
if (lines.size() != lineIds.size()) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||||
"One or more selected inbound lines do not exist in this tenant");
|
"所选入仓明细不存在,请刷新后重试");
|
||||||
}
|
}
|
||||||
String fbaWarehouseCode = commonFbaWarehouse(lines);
|
String fbaWarehouseCode = commonFbaWarehouse(lines);
|
||||||
|
|
||||||
// The row locks serialize concurrent submissions selecting the same inbound lines.
|
// The row locks serialize concurrent submissions selecting the same inbound lines.
|
||||||
existing = find(customerId, appointmentNo);
|
existing = find(appointmentNo);
|
||||||
if (existing != null) {
|
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();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
InboundFbaAppointment appointment = new InboundFbaAppointment();
|
InboundFbaAppointment appointment = new InboundFbaAppointment();
|
||||||
appointment.setId(IdWorker.getId());
|
appointment.setId(IdWorker.getId());
|
||||||
appointment.setCustomerId(customerId);
|
|
||||||
appointment.setAppointmentNo(appointmentNo);
|
appointment.setAppointmentNo(appointmentNo);
|
||||||
appointment.setAppointmentAt(request.appointmentAt());
|
appointment.setAppointmentAt(request.appointmentAt());
|
||||||
|
appointment.setAppointmentEndAt(request.appointmentEndAt());
|
||||||
|
appointment.setVersion(0);
|
||||||
appointment.setExpectedLoadingAt(request.expectedLoadingAt());
|
appointment.setExpectedLoadingAt(request.expectedLoadingAt());
|
||||||
appointment.setAppointmentStatus(status);
|
appointment.setAppointmentStatus(status);
|
||||||
appointment.setAppointmentRemark(remark);
|
appointment.setAppointmentRemark(remark);
|
||||||
@@ -78,9 +107,9 @@ public class InboundFbaAppointmentService {
|
|||||||
try {
|
try {
|
||||||
appointmentMapper.insert(appointment);
|
appointmentMapper.insert(appointment);
|
||||||
} catch (DuplicateKeyException exception) {
|
} catch (DuplicateKeyException exception) {
|
||||||
InboundFbaAppointment concurrent = find(customerId, appointmentNo);
|
InboundFbaAppointment concurrent = find(appointmentNo);
|
||||||
if (concurrent != null) {
|
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;
|
throw exception;
|
||||||
}
|
}
|
||||||
@@ -89,6 +118,7 @@ public class InboundFbaAppointmentService {
|
|||||||
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
||||||
item.setId(IdWorker.getId());
|
item.setId(IdWorker.getId());
|
||||||
item.setAppointmentId(appointment.getId());
|
item.setAppointmentId(appointment.getId());
|
||||||
|
item.setCustomerId(line.customerId());
|
||||||
item.setPlanId(line.planId());
|
item.setPlanId(line.planId());
|
||||||
item.setLineId(line.lineId());
|
item.setLineId(line.lineId());
|
||||||
item.setOrderId(line.orderId());
|
item.setOrderId(line.orderId());
|
||||||
@@ -101,39 +131,75 @@ public class InboundFbaAppointmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public FbaAppointmentResponse detail(String customerId, String appointmentNo) {
|
public FbaAppointmentResponse detail(String appointmentNo) {
|
||||||
InboundFbaAppointment appointment = find(customerId,
|
InboundFbaAppointment appointment = find(requiredTrimmed(appointmentNo, "Appointment number is required"));
|
||||||
requiredTrimmed(appointmentNo, "Appointment number is required"));
|
|
||||||
if (appointment == null) {
|
if (appointment == null) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "FBA appointment not found");
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "FBA appointment not found");
|
||||||
}
|
}
|
||||||
return response(appointment, items(appointment.getId()));
|
return response(appointment, items(appointment.getId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
|
||||||
public FbaAppointmentResponse update(String customerId, String appointmentNo,
|
public FbaAppointmentResponse update(String appointmentNo,
|
||||||
UpdateFbaAppointmentRequest request) {
|
UpdateFbaAppointmentRequest request) {
|
||||||
InboundFbaAppointment appointment = find(customerId,
|
InboundFbaAppointment appointment = find(requiredTrimmed(appointmentNo, "Appointment number is required"));
|
||||||
requiredTrimmed(appointmentNo, "Appointment number is required"));
|
|
||||||
if (appointment == null) {
|
if (appointment == null) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "FBA appointment not found");
|
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.setAppointmentAt(request.appointmentAt());
|
||||||
appointment.setExpectedLoadingAt(request.expectedLoadingAt());
|
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.setAppointmentRemark(trimToNull(request.appointmentRemark()));
|
||||||
appointment.setUpdatedAt(LocalDateTime.now());
|
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()));
|
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,
|
private FbaAppointmentResponse verifyReplay(InboundFbaAppointment existing, List<Long> requestedLineIds,
|
||||||
LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt,
|
LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt,
|
||||||
String status, String remark) {
|
String status, String remark, LocalDateTime appointmentEndAt) {
|
||||||
List<InboundFbaAppointmentItem> existingItems = items(existing.getId());
|
List<InboundFbaAppointmentItem> existingItems = items(existing.getId());
|
||||||
Set<Long> existingLineIds = existingItems.stream()
|
Set<Long> existingLineIds = existingItems.stream()
|
||||||
.map(InboundFbaAppointmentItem::getLineId).collect(java.util.stream.Collectors.toSet());
|
.map(InboundFbaAppointmentItem::getLineId).collect(java.util.stream.Collectors.toSet());
|
||||||
if (!Objects.equals(existing.getAppointmentAt(), appointmentAt)
|
if (!Objects.equals(existing.getAppointmentAt(), appointmentAt)
|
||||||
|
|| !Objects.equals(existing.getAppointmentEndAt(), appointmentEndAt)
|
||||||
|| !Objects.equals(existing.getExpectedLoadingAt(), expectedLoadingAt)
|
|| !Objects.equals(existing.getExpectedLoadingAt(), expectedLoadingAt)
|
||||||
|| !Objects.equals(existing.getAppointmentStatus(), status)
|
|| !Objects.equals(existing.getAppointmentStatus(), status)
|
||||||
|| !Objects.equals(existing.getAppointmentRemark(), remark)
|
|| !Objects.equals(existing.getAppointmentRemark(), remark)
|
||||||
@@ -155,10 +221,9 @@ public class InboundFbaAppointmentService {
|
|||||||
return warehouses.get(0);
|
return warehouses.get(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private InboundFbaAppointment find(String customerId, String appointmentNo) {
|
private InboundFbaAppointment find(String appointmentNo) {
|
||||||
LambdaQueryWrapper<InboundFbaAppointment> wrapper = new LambdaQueryWrapper<InboundFbaAppointment>()
|
LambdaQueryWrapper<InboundFbaAppointment> wrapper = new LambdaQueryWrapper<InboundFbaAppointment>()
|
||||||
.eq(InboundFbaAppointment::getAppointmentNo, appointmentNo);
|
.eq(InboundFbaAppointment::getAppointmentNo, appointmentNo);
|
||||||
if (customerId != null) wrapper.eq(InboundFbaAppointment::getCustomerId, customerId);
|
|
||||||
return appointmentMapper.selectOne(wrapper);
|
return appointmentMapper.selectOne(wrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,13 +235,20 @@ public class InboundFbaAppointmentService {
|
|||||||
|
|
||||||
private FbaAppointmentResponse response(InboundFbaAppointment appointment,
|
private FbaAppointmentResponse response(InboundFbaAppointment appointment,
|
||||||
List<InboundFbaAppointmentItem> items) {
|
List<InboundFbaAppointmentItem> items) {
|
||||||
return new FbaAppointmentResponse(appointment.getId(), appointment.getCustomerId(),
|
return new FbaAppointmentResponse(appointment.getId(),
|
||||||
appointment.getAppointmentNo(), appointment.getAppointmentAt(), appointment.getExpectedLoadingAt(),
|
appointment.getAppointmentNo(), appointment.getAppointmentAt(), appointment.getExpectedLoadingAt(),
|
||||||
appointment.getAppointmentStatus(),
|
appointment.getAppointmentStatus(),
|
||||||
appointment.getAppointmentRemark(), appointment.getFbaWarehouseCode(), appointment.getSelectedCount(),
|
appointment.getAppointmentRemark(), appointment.getFbaWarehouseCode(), appointment.getSelectedCount(),
|
||||||
appointment.getCreatedBy(), appointment.getCreatedAt(), appointment.getUpdatedAt(),
|
appointment.getCreatedBy(), appointment.getCreatedAt(), appointment.getUpdatedAt(),
|
||||||
items.stream().map(item -> new FbaAppointmentResponse.Item(item.getId(), item.getPlanId(),
|
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) {
|
private static String normalizeStatus(String value) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.security;
|
|||||||
|
|
||||||
/** Canonical authorities for overseas browser APIs. */
|
/** Canonical authorities for overseas browser APIs. */
|
||||||
public final class OverseasPermissions {
|
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_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_EDIT = "platform:overseas-warehouse:last-mile-order:edit";
|
||||||
public static final String LAST_MILE_ORDER_EXPORT = "platform:overseas-warehouse:last-mile-order:export";
|
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 (
|
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
|
||||||
id BIGINT NOT NULL COMMENT '主键ID',
|
id BIGINT NOT NULL COMMENT '主键ID',
|
||||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
|
||||||
appointment_no VARCHAR(64) NOT NULL COMMENT '预约单号',
|
appointment_no VARCHAR(64) NOT NULL COMMENT '预约单号',
|
||||||
appointment_at DATETIME(3) 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 '预计装车时间',
|
expected_loading_at DATETIME(3) NOT NULL COMMENT '预计装车时间',
|
||||||
appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '预约状态',
|
appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '预约状态',
|
||||||
appointment_remark VARCHAR(1000) NULL 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 '创建时间',
|
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 '更新时间',
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_ll_fba_appointment_no (customer_id, appointment_no),
|
UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
|
||||||
KEY idx_ll_fba_appointment_list (customer_id, appointment_status, fba_warehouse_code, created_at)
|
KEY idx_ll_fba_appointment_list (appointment_at, appointment_status, fba_warehouse_code)
|
||||||
) ENGINE=InnoDB COMMENT='FBA入仓预约单';
|
) ENGINE=InnoDB COMMENT='FBA入仓预约单';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item (
|
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item (
|
||||||
id BIGINT NOT NULL COMMENT '主键ID',
|
id BIGINT NOT NULL COMMENT '主键ID',
|
||||||
appointment_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',
|
plan_id BIGINT NOT NULL COMMENT '入仓计划ID',
|
||||||
line_id BIGINT NOT NULL COMMENT '入仓计划明细ID',
|
line_id BIGINT NOT NULL COMMENT '入仓计划明细ID',
|
||||||
order_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 '创建时间',
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_ll_fba_appointment_item_line (appointment_id, line_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_plan (plan_id),
|
||||||
KEY idx_ll_fba_appointment_item_order (order_id)
|
KEY idx_ll_fba_appointment_item_order (order_id)
|
||||||
) ENGINE=InnoDB COMMENT='FBA入仓预约明细';
|
) 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
|
ALTER TABLE ll_loading_order_status_history
|
||||||
MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键',
|
MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键',
|
||||||
MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单 ID',
|
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_pda_operation"));
|
||||||
assertTrue(schema.contains("create table if not exists ll_inbound_pallet_merge"));
|
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("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("expected_loading_at"));
|
||||||
assertTrue(schema.contains("change column tenant_id customer_id"));
|
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 ("));
|
||||||
assertTrue(schema.contains("set expected_loading_at=appointment_at"));
|
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("planned_arrival_date"));
|
||||||
assertTrue(schema.contains("creation_mode varchar(32)"));
|
assertTrue(schema.contains("creation_mode varchar(32)"));
|
||||||
assertTrue(schema.contains("modify column creation_mode varchar(32)"));
|
assertTrue(schema.contains("modify column creation_mode varchar(32)"));
|
||||||
|
|||||||
+15
-4
@@ -53,14 +53,14 @@ class InboundPlanControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void platformAppointmentUpdateUsesExplicitCustomerScope() {
|
void platformAppointmentUpdateUsesSharedAppointmentNumber() {
|
||||||
UpdateFbaAppointmentRequest request = new UpdateFbaAppointmentRequest("100",
|
UpdateFbaAppointmentRequest request = new UpdateFbaAppointmentRequest(
|
||||||
LocalDateTime.of(2026, 8, 20, 10, 30), LocalDateTime.of(2026, 8, 21, 8, 0),
|
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);
|
controller.updateFbaAppointment(platform(), "APT-1", request);
|
||||||
|
|
||||||
verify(appointmentService).update("100", "APT-1", request);
|
verify(appointmentService).update("APT-1", request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -98,6 +98,17 @@ class InboundPlanControllerTest {
|
|||||||
org.mockito.ArgumentMatchers.any());
|
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() {
|
private static AuthenticatedUser platform() {
|
||||||
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB",
|
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB",
|
||||||
Set.of("platform:overseas-warehouse:inbound-order:list"));
|
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())
|
Map<String, Method> methods = Arrays.stream(InboundPlanController.class.getDeclaredMethods())
|
||||||
.collect(Collectors.toMap(Method::getName, method -> method));
|
.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);
|
Method method = methods.get(name);
|
||||||
assertEquals("hasAuthority('" + LIST + "')", method.getAnnotation(PreAuthorize.class).value());
|
assertEquals("hasAuthority('" + LIST + "')", method.getAnnotation(PreAuthorize.class).value());
|
||||||
assertEquals("Required authority: " + LIST, method.getAnnotation(Operation.class).description());
|
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
|
@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(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_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_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_item(id BIGINT,line_id BIGINT,appointment_id BIGINT,customer_id VARCHAR(20))");
|
||||||
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(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_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_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))");
|
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();
|
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) {
|
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_order VALUES(?,?,?)", id, owner, status);
|
||||||
db.update("INSERT INTO ll_dispatch_item VALUES(?,?,?,3)", id, itemOwner, id);
|
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 org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
@@ -46,49 +47,54 @@ class InboundFbaAppointmentServiceTest {
|
|||||||
ReflectionTestUtils.setField(service, "itemMapper", itemMapper);
|
ReflectionTestUtils.setField(service, "itemMapper", itemMapper);
|
||||||
ReflectionTestUtils.setField(service, "queryMapper", queryMapper);
|
ReflectionTestUtils.setField(service, "queryMapper", queryMapper);
|
||||||
lenient().when(itemMapper.selectList(any())).thenReturn(List.of());
|
lenient().when(itemMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
lenient().when(appointmentMapper.update(any(InboundFbaAppointment.class), any())).thenReturn(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createsOneBatchForSelectedLinesAndFreezesFbaWarehouse() throws Exception {
|
void createsOneBatchForSelectedLinesAndFreezesFbaWarehouse() throws Exception {
|
||||||
assertEquals(true, InboundFbaAppointmentService.class
|
assertEquals(true, InboundFbaAppointmentService.class
|
||||||
.getMethod("batchCreate", String.class, String.class, BatchFbaAppointmentRequest.class)
|
.getMethod("batchCreate", String.class, BatchFbaAppointmentRequest.class)
|
||||||
.getAnnotation(Transactional.class) != null);
|
.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")));
|
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("APT-1", result.appointmentNo());
|
||||||
assertEquals("ONT8", result.fbaWarehouseCode());
|
assertEquals("ONT8", result.fbaWarehouseCode());
|
||||||
assertEquals(expectedLoadingAt, result.expectedLoadingAt());
|
assertEquals(expectedLoadingAt, result.expectedLoadingAt());
|
||||||
assertEquals(2, result.selectedCount());
|
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(appointmentMapper).insert(any(InboundFbaAppointment.class));
|
||||||
verify(itemMapper, org.mockito.Mockito.times(2)).insert(any(InboundFbaAppointmentItem.class));
|
verify(itemMapper, org.mockito.Mockito.times(2)).insert(any(InboundFbaAppointmentItem.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsMixedFbaWarehouses() {
|
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")));
|
line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "LAX9")));
|
||||||
|
|
||||||
assertThrows(ResponseStatusException.class,
|
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));
|
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsMissingLineFromAnotherTenant() {
|
void rejectsMissingSelectedLine() {
|
||||||
when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(line(11L, "IN-11", "ONT8")));
|
when(queryMapper.lockLines(List.of(11L, 12L))).thenReturn(List.of(line(11L, "IN-11", "ONT8")));
|
||||||
|
|
||||||
assertThrows(ResponseStatusException.class,
|
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
|
@Test
|
||||||
void duplicateLineIdsAreRejectedBeforeDatabaseAccess() {
|
void duplicateLineIdsAreRejectedBeforeDatabaseAccess() {
|
||||||
assertThrows(ResponseStatusException.class,
|
assertThrows(ResponseStatusException.class,
|
||||||
() -> service.batchCreate("100", "7", request(List.of(11L, 11L), "APT-1")));
|
() -> service.batchCreate("7", request(List.of(11L, 11L), "APT-1")));
|
||||||
verify(queryMapper, never()).lockLines(any(), any());
|
verify(queryMapper, never()).lockLines(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -101,7 +107,7 @@ class InboundFbaAppointmentServiceTest {
|
|||||||
when(appointmentMapper.selectOne(any())).thenReturn(existing);
|
when(appointmentMapper.selectOne(any())).thenReturn(existing);
|
||||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
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());
|
assertEquals(99L, result.id());
|
||||||
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
|
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
|
||||||
@@ -115,7 +121,7 @@ class InboundFbaAppointmentServiceTest {
|
|||||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||||
|
|
||||||
assertThrows(ResponseStatusException.class,
|
assertThrows(ResponseStatusException.class,
|
||||||
() -> service.batchCreate("100", "7", request(List.of(12L), "APT-1")));
|
() -> service.batchCreate("7", request(List.of(12L), "APT-1")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -124,17 +130,17 @@ class InboundFbaAppointmentServiceTest {
|
|||||||
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
||||||
item.setLineId(11L);
|
item.setLineId(11L);
|
||||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||||
BatchFbaAppointmentRequest changed = new BatchFbaAppointmentRequest("100", List.of(11L), "APT-1",
|
BatchFbaAppointmentRequest changed = new BatchFbaAppointmentRequest(List.of(11L), "APT-1",
|
||||||
appointmentAt, expectedLoadingAt.plusHours(1), "SUCCESS", "dock");
|
appointmentAt, expectedLoadingAt.plusHours(1), "SUCCESS", "dock", null);
|
||||||
|
|
||||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||||
() -> service.batchCreate("100", "7", changed));
|
() -> service.batchCreate("7", changed));
|
||||||
assertEquals(409, exception.getStatusCode().value());
|
assertEquals(409, exception.getStatusCode().value());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void stringSnowflakeLineIdsDeserializeWithoutPrecisionLoss() throws Exception {
|
void stringSnowflakeLineIdsDeserializeWithoutPrecisionLoss() throws Exception {
|
||||||
String json = "{\"customerId\":\"100\",\"lineIds\":[\"9007199254740993\"],"
|
String json = "{\"lineIds\":[\"9007199254740993\"],"
|
||||||
+ "\"appointmentNo\":\"APT-1\",\"appointmentAt\":\"2026-08-20T10:30:00\","
|
+ "\"appointmentNo\":\"APT-1\",\"appointmentAt\":\"2026-08-20T10:30:00\","
|
||||||
+ "\"expectedLoadingAt\":\"2026-08-21T08:00:00\",\"appointmentStatus\":\"PENDING\"}";
|
+ "\"expectedLoadingAt\":\"2026-08-21T08:00:00\",\"appointmentStatus\":\"PENDING\"}";
|
||||||
|
|
||||||
@@ -151,28 +157,122 @@ class InboundFbaAppointmentServiceTest {
|
|||||||
when(appointmentMapper.selectOne(any())).thenReturn(existing);
|
when(appointmentMapper.selectOne(any())).thenReturn(existing);
|
||||||
LocalDateTime loadingAt = expectedLoadingAt.plusHours(2);
|
LocalDateTime loadingAt = expectedLoadingAt.plusHours(2);
|
||||||
|
|
||||||
var result = service.update("100", "APT-1", new UpdateFbaAppointmentRequest(
|
var result = service.update("APT-1", new UpdateFbaAppointmentRequest(
|
||||||
"100", appointmentAt.plusDays(1), loadingAt, "PENDING", "updated"));
|
appointmentAt.plusDays(1), loadingAt, "PENDING", "updated", null, 0));
|
||||||
|
|
||||||
assertEquals(loadingAt, result.expectedLoadingAt());
|
assertEquals(loadingAt, result.expectedLoadingAt());
|
||||||
assertEquals("PENDING", result.appointmentStatus());
|
assertEquals("PENDING", result.appointmentStatus());
|
||||||
verify(appointmentMapper).updateById(existing);
|
verify(appointmentMapper).update(org.mockito.ArgumentMatchers.eq(existing), any());
|
||||||
verify(itemMapper, never()).insert(any(InboundFbaAppointmentItem.class));
|
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) {
|
private BatchFbaAppointmentRequest request(List<Long> lineIds, String number) {
|
||||||
return new BatchFbaAppointmentRequest("100", lineIds, number, appointmentAt, expectedLoadingAt,
|
return new BatchFbaAppointmentRequest(lineIds, number, appointmentAt, expectedLoadingAt,
|
||||||
"SUCCESS", "dock");
|
"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) {
|
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) {
|
private InboundFbaAppointment appointment(Long id, String number, String fba) {
|
||||||
InboundFbaAppointment value = new InboundFbaAppointment();
|
InboundFbaAppointment value = new InboundFbaAppointment();
|
||||||
value.setId(id);
|
value.setId(id);
|
||||||
value.setCustomerId("100");
|
|
||||||
value.setAppointmentNo(number);
|
value.setAppointmentNo(number);
|
||||||
value.setAppointmentAt(appointmentAt);
|
value.setAppointmentAt(appointmentAt);
|
||||||
value.setExpectedLoadingAt(expectedLoadingAt);
|
value.setExpectedLoadingAt(expectedLoadingAt);
|
||||||
@@ -180,6 +280,7 @@ class InboundFbaAppointmentServiceTest {
|
|||||||
value.setAppointmentRemark("dock");
|
value.setAppointmentRemark("dock");
|
||||||
value.setFbaWarehouseCode(fba);
|
value.setFbaWarehouseCode(fba);
|
||||||
value.setSelectedCount(1);
|
value.setSelectedCount(1);
|
||||||
|
value.setVersion(0);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -81,7 +81,9 @@ class InboundPlanPaginationTest {
|
|||||||
String sql = String.join("", InboundPlanQueryMapper.class
|
String sql = String.join("", InboundPlanQueryMapper.class
|
||||||
.getMethod("rows", String.class, InboundPlanRequests.Query.class, long.class, long.class)
|
.getMethod("rows", String.class, InboundPlanRequests.Query.class, long.class, long.class)
|
||||||
.getAnnotation(org.apache.ibatis.annotations.Select.class).value()) + InboundPlanQueryMapper.FROM_AND_FILTER;
|
.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",
|
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'");
|
"dispatch.customer_id=di.customer_id", "dispatch.status<>'CANCELLED'");
|
||||||
assertThat(sql).contains("p.overseas_warehouse_id overseasWarehouseId", "o.overseas_warehouse_code overseasWarehouseCode",
|
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 appointmentNo = `FBA-${suffix}`.slice(0, 64)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const appointmentBody = {
|
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),
|
appointmentAt: new Date(now + 24 * 60 * 60_000).toISOString().slice(0, 19),
|
||||||
expectedLoadingAt: new Date(now + 26 * 60 * 60_000).toISOString().slice(0, 19),
|
expectedLoadingAt: new Date(now + 26 * 60 * 60_000).toISOString().slice(0, 19),
|
||||||
appointmentStatus: 'PENDING', appointmentRemark: context.marker,
|
appointmentStatus: 'PENDING', appointmentRemark: context.marker,
|
||||||
@@ -108,16 +108,17 @@ async function inboundAdminLifecycle(context) {
|
|||||||
})
|
})
|
||||||
context.addStep(MODULE, 'FBA预约同单号异载荷返回A0409', appointmentConflict,
|
context.addStep(MODULE, 'FBA预约同单号异载荷返回A0409', appointmentConflict,
|
||||||
conflict(appointmentConflict), trace(appointmentConflict), OPTIONS)
|
conflict(appointmentConflict), trace(appointmentConflict), OPTIONS)
|
||||||
const detail = await request(context, 'GET', '查询FBA预约详情', query(
|
const detail = await request(context, 'GET', '查询FBA预约详情',
|
||||||
`${PLAN_BASE}/fba-appointments/${encodeURIComponent(appointmentNo)}`, { customerId }))
|
`${PLAN_BASE}/fba-appointments/${encodeURIComponent(appointmentNo)}`)
|
||||||
context.assert(MODULE, 'FBA预约详情冻结仓码和所选明细', null,
|
context.assert(MODULE, 'FBA预约详情冻结仓码和所选明细', null,
|
||||||
detail?.appointmentNo === appointmentNo && detail?.fbaWarehouseCode === sample.fbaWarehouseCode
|
detail?.appointmentNo === appointmentNo && detail?.fbaWarehouseCode === sample.fbaWarehouseCode
|
||||||
&& detail?.items?.length === fbaLines.length,
|
&& detail?.items?.length === fbaLines.length,
|
||||||
`appointmentNo=${detail?.appointmentNo || 'missing'}, warehouse=${detail?.fbaWarehouseCode || 'missing'}`, OPTIONS)
|
`appointmentNo=${detail?.appointmentNo || 'missing'}, warehouse=${detail?.fbaWarehouseCode || 'missing'}`, OPTIONS)
|
||||||
const cancelled = await request(context, 'PUT', '取消本轮FBA预约收口',
|
const cancelled = await request(context, 'PUT', '取消本轮FBA预约收口',
|
||||||
`${PLAN_BASE}/fba-appointments/${encodeURIComponent(appointmentNo)}`, {
|
`${PLAN_BASE}/fba-appointments/${encodeURIComponent(appointmentNo)}`, {
|
||||||
customerId, appointmentAt: appointmentBody.appointmentAt,
|
appointmentAt: appointmentBody.appointmentAt,
|
||||||
expectedLoadingAt: appointmentBody.expectedLoadingAt,
|
expectedLoadingAt: appointmentBody.expectedLoadingAt,
|
||||||
|
version: detail.version, appointmentEndAt: detail.appointmentEndAt,
|
||||||
appointmentStatus: 'CANCELLED', appointmentRemark: `${context.marker}-cancelled`,
|
appointmentStatus: 'CANCELLED', appointmentRemark: `${context.marker}-cancelled`,
|
||||||
})
|
})
|
||||||
context.assert(MODULE, 'FBA预约最终为CANCELLED', null,
|
context.assert(MODULE, 'FBA预约最终为CANCELLED', null,
|
||||||
|
|||||||
Reference in New Issue
Block a user