feat: support shared FBA appointments and arrival calendar
This commit is contained in:
+37
-9
@@ -125,20 +125,31 @@ public class InboundPlanController {
|
||||
public ApiResponse<FbaAppointmentResponse> batchFbaAppointment(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody BatchFbaAppointmentRequest request) {
|
||||
String customerId = scope(user, request.customerId());
|
||||
requireAppointmentPlatform(user);
|
||||
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
return ApiResponse.success(appointmentService.batchCreate(customerId, operatorId, request));
|
||||
return ApiResponse.success(appointmentService.batchCreate(operatorId, request));
|
||||
}
|
||||
|
||||
@GetMapping("/fba-appointments/calendar")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.APPOINTMENT_LIST + "')")
|
||||
@Operation(summary = "按日期范围查询 FBA 预约日历", description = "Required authority: " + OverseasPermissions.APPOINTMENT_LIST)
|
||||
public ApiResponse<java.util.List<FbaAppointmentResponse>> fbaAppointmentCalendar(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate from,
|
||||
@RequestParam @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate to) {
|
||||
requireAppointmentPlatform(user);
|
||||
return ApiResponse.success(appointmentService.calendar(from, to));
|
||||
}
|
||||
|
||||
@GetMapping("/fba-appointments/{appointmentNo}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
|
||||
@PreAuthorize("hasAnyAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "','" + OverseasPermissions.APPOINTMENT_LIST + "')")
|
||||
/** 查询 FBA 预约及其选中的入仓明细。 */
|
||||
@Operation(summary = "查询 FBA 预约详情", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
|
||||
@Operation(summary = "查询 FBA 预约详情", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST + " or " + OverseasPermissions.APPOINTMENT_LIST)
|
||||
public ApiResponse<FbaAppointmentResponse> fbaAppointmentDetail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String appointmentNo,
|
||||
@RequestParam(required = false) String customerId) {
|
||||
return ApiResponse.success(appointmentService.detail(scope(user, customerId), appointmentNo));
|
||||
@PathVariable String appointmentNo) {
|
||||
requireAppointmentPlatform(user);
|
||||
return ApiResponse.success(appointmentService.detail(appointmentNo));
|
||||
}
|
||||
|
||||
@PutMapping("/fba-appointments/{appointmentNo}")
|
||||
@@ -148,8 +159,25 @@ public class InboundPlanController {
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String appointmentNo,
|
||||
@Valid @RequestBody UpdateFbaAppointmentRequest request) {
|
||||
return ApiResponse.success(appointmentService.update(scope(user, request.customerId()),
|
||||
appointmentNo, request));
|
||||
requireAppointmentPlatform(user);
|
||||
return ApiResponse.success(appointmentService.update(appointmentNo, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/fba-appointments/{appointmentNo}")
|
||||
@PreAuthorize("hasAuthority('platform:appointment:delete')")
|
||||
@Operation(summary = "删除未关联排车的预约", description = "Required authority: platform:appointment:delete")
|
||||
public ApiResponse<Void> deleteFbaAppointment(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String appointmentNo, @RequestParam int version) {
|
||||
requireAppointmentPlatform(user);
|
||||
appointmentService.delete(appointmentNo, version);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
private void requireAppointmentPlatform(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "预约由平台统一管理");
|
||||
}
|
||||
}
|
||||
|
||||
private String scope(AuthenticatedUser user, String requestedCustomer) {
|
||||
|
||||
+2
-2
@@ -9,12 +9,12 @@ import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public record BatchFbaAppointmentRequest(
|
||||
String customerId,
|
||||
@NotEmpty @Size(max = 500) List<@NotNull Long> lineIds,
|
||||
@NotBlank @Size(max = 64) String appointmentNo,
|
||||
@NotNull LocalDateTime appointmentAt,
|
||||
@NotNull LocalDateTime expectedLoadingAt,
|
||||
@NotBlank @Size(max = 32) String appointmentStatus,
|
||||
@Size(max = 1000) String appointmentRemark
|
||||
@Size(max = 1000) String appointmentRemark,
|
||||
LocalDateTime appointmentEndAt
|
||||
) {
|
||||
}
|
||||
|
||||
+4
-3
@@ -5,7 +5,6 @@ import java.util.List;
|
||||
|
||||
public record FbaAppointmentResponse(
|
||||
Long id,
|
||||
String customerId,
|
||||
String appointmentNo,
|
||||
LocalDateTime appointmentAt,
|
||||
LocalDateTime expectedLoadingAt,
|
||||
@@ -16,9 +15,11 @@ public record FbaAppointmentResponse(
|
||||
String createdBy,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt,
|
||||
List<Item> items
|
||||
List<Item> items,
|
||||
LocalDateTime appointmentEndAt,
|
||||
Integer version
|
||||
) {
|
||||
public record Item(Long id, Long planId, Long lineId, Long orderId, String inboundCode,
|
||||
String fbaWarehouseCode) {
|
||||
String fbaWarehouseCode, String customerId) {
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -7,10 +7,11 @@ import jakarta.validation.constraints.Size;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record UpdateFbaAppointmentRequest(
|
||||
String customerId,
|
||||
@NotNull LocalDateTime appointmentAt,
|
||||
@NotNull LocalDateTime expectedLoadingAt,
|
||||
@NotBlank @Size(max = 32) String appointmentStatus,
|
||||
@Size(max = 1000) String appointmentRemark
|
||||
@Size(max = 1000) String appointmentRemark,
|
||||
LocalDateTime appointmentEndAt,
|
||||
@NotNull @jakarta.validation.constraints.Min(0) Integer version
|
||||
) {
|
||||
}
|
||||
|
||||
+4
-1
@@ -12,11 +12,14 @@ import java.time.LocalDateTime;
|
||||
public class InboundFbaAppointment {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private String customerId;
|
||||
private String appointmentNo;
|
||||
private LocalDateTime appointmentAt;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private LocalDateTime appointmentEndAt;
|
||||
private Integer version;
|
||||
private LocalDateTime expectedLoadingAt;
|
||||
private String appointmentStatus;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private String appointmentRemark;
|
||||
private String fbaWarehouseCode;
|
||||
private Integer selectedCount;
|
||||
|
||||
+1
@@ -13,6 +13,7 @@ public class InboundFbaAppointmentItem {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private Long appointmentId;
|
||||
private String customerId;
|
||||
private Long planId;
|
||||
private Long lineId;
|
||||
private Long orderId;
|
||||
|
||||
+18
-4
@@ -11,12 +11,26 @@ import java.util.List;
|
||||
public interface InboundFbaAppointmentQueryMapper {
|
||||
@Select("<script>" +
|
||||
"SELECT l.id AS lineId,l.plan_id AS planId,l.order_id AS orderId,l.inbound_code AS inboundCode," +
|
||||
"l.fba_warehouse_code AS fbaWarehouseCode " +
|
||||
"l.fba_warehouse_code AS fbaWarehouseCode,p.customer_id AS customerId " +
|
||||
"FROM ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id " +
|
||||
"WHERE p.customer_id=#{customerId} AND l.id IN " +
|
||||
"WHERE l.id IN " +
|
||||
"<foreach collection='lineIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||
"ORDER BY l.id FOR UPDATE" +
|
||||
"</script>")
|
||||
List<FbaAppointmentLineSnapshot> lockLines(@Param("customerId") String customerId,
|
||||
@Param("lineIds") List<Long> lineIds);
|
||||
List<FbaAppointmentLineSnapshot> lockLines(@Param("lineIds") List<Long> lineIds);
|
||||
|
||||
@Select("<script>SELECT COUNT(*) FROM ll_inbound_fba_appointment_item i " +
|
||||
"JOIN ll_inbound_fba_appointment a ON a.id=i.appointment_id " +
|
||||
"WHERE a.appointment_status NOT IN ('CANCELLED','FAILED') " +
|
||||
"<if test='excludedId != null'>AND a.id<>#{excludedId} </if>AND i.line_id IN " +
|
||||
"<foreach collection='lineIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
|
||||
long countActiveAppointments(@Param("lineIds") List<Long> lineIds, @Param("excludedId") Long excludedId);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM ll_inbound_fba_appointment_item i
|
||||
JOIN ll_dispatch_item di ON di.order_id=i.order_id AND di.customer_id=i.customer_id
|
||||
JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id AND d.customer_id=di.customer_id
|
||||
WHERE i.appointment_id=#{appointmentId} AND d.status<>'CANCELLED'
|
||||
""")
|
||||
long countActiveDispatches(@Param("appointmentId") Long appointmentId);
|
||||
}
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ public interface InboundPlanMapper extends BaseMapper<InboundPlan> {
|
||||
"<if test='q.fbaWarehouseCode != null and q.fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{q.fbaWarehouseCode}</if>" +
|
||||
"<if test='q.appointmentStatus != null and q.appointmentStatus != \"\" and q.appointmentStatus != \"NOT_APPOINTED\"'> " +
|
||||
"AND EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id " +
|
||||
"WHERE ai.plan_id=p.id AND a.customer_id=#{customerId} AND a.appointment_status=#{q.appointmentStatus})</if>" +
|
||||
"WHERE ai.plan_id=p.id AND ai.customer_id=#{customerId} AND a.appointment_status=#{q.appointmentStatus})</if>" +
|
||||
"<if test='q.appointmentStatus == \"NOT_APPOINTED\"'> AND NOT EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai " +
|
||||
"WHERE ai.plan_id=p.id)</if>" +
|
||||
"<if test='q.createdFrom != null'> AND p.created_at >=#{q.createdFrom}</if>" +
|
||||
|
||||
+3
-3
@@ -18,14 +18,14 @@ public interface InboundPlanQueryMapper {
|
||||
|
||||
String FROM_AND_FILTER = " FROM ll_inbound_plan_line l" +
|
||||
" JOIN ll_inbound_plan p ON p.id=l.plan_id" +
|
||||
" JOIN ll_order o ON o.id=l.order_id" +
|
||||
" JOIN ll_order o ON o.id=l.order_id AND o.customer_id=p.customer_id" +
|
||||
"<if test='customerId != null and customerId != \"\"'> AND p.customer_id=#{customerId} AND o.customer_id=#{customerId}</if>" +
|
||||
" LEFT JOIN ll_inbound_fba_appointment_item ai ON ai.line_id=l.id" +
|
||||
" AND ai.id=(SELECT ai2.id FROM ll_inbound_fba_appointment_item ai2" +
|
||||
" JOIN ll_inbound_fba_appointment a2 ON a2.id=ai2.appointment_id" +
|
||||
" WHERE ai2.line_id=l.id <if test='customerId != null and customerId != \"\"'> AND a2.customer_id=#{customerId}</if>" +
|
||||
" WHERE ai2.line_id=l.id AND ai2.customer_id=p.customer_id" +
|
||||
" ORDER BY a2.created_at DESC,ai2.id DESC LIMIT 1)" +
|
||||
" LEFT JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id <if test='customerId != null and customerId != \"\"'> AND a.customer_id=#{customerId}</if>" +
|
||||
" LEFT JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id" +
|
||||
" WHERE 1=1" +
|
||||
"<if test='q.planNo != null and q.planNo != \"\"'> AND p.plan_no LIKE CONCAT('%',#{q.planNo},'%')</if>" +
|
||||
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ public record FbaAppointmentLineSnapshot(
|
||||
Long planId,
|
||||
Long orderId,
|
||||
String inboundCode,
|
||||
String fbaWarehouseCode
|
||||
String fbaWarehouseCode,
|
||||
String customerId
|
||||
) {
|
||||
}
|
||||
|
||||
+97
-25
@@ -20,6 +20,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -32,8 +34,29 @@ public class InboundFbaAppointmentService {
|
||||
@Resource private InboundFbaAppointmentItemMapper itemMapper;
|
||||
@Resource private InboundFbaAppointmentQueryMapper queryMapper;
|
||||
|
||||
@Transactional
|
||||
public FbaAppointmentResponse batchCreate(String customerId, String operatorId,
|
||||
/** A bounded calendar read uses two batch queries, never one detail query per appointment. */
|
||||
@Transactional(readOnly = true)
|
||||
public List<FbaAppointmentResponse> calendar(LocalDate from, LocalDate to) {
|
||||
if (from == null || to == null || !to.isAfter(from) || ChronoUnit.DAYS.between(from, to) > 42) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历查询范围必须为 1 至 42 天");
|
||||
}
|
||||
List<InboundFbaAppointment> appointments = appointmentMapper.selectList(
|
||||
new LambdaQueryWrapper<InboundFbaAppointment>()
|
||||
.ge(InboundFbaAppointment::getAppointmentAt, from.atStartOfDay())
|
||||
.lt(InboundFbaAppointment::getAppointmentAt, to.atStartOfDay())
|
||||
.orderByAsc(InboundFbaAppointment::getAppointmentAt, InboundFbaAppointment::getId));
|
||||
if (appointments.isEmpty()) return List.of();
|
||||
var groupedItems = itemMapper.selectList(new LambdaQueryWrapper<InboundFbaAppointmentItem>()
|
||||
.in(InboundFbaAppointmentItem::getAppointmentId,
|
||||
appointments.stream().map(InboundFbaAppointment::getId).toList())
|
||||
.orderByAsc(InboundFbaAppointmentItem::getLineId))
|
||||
.stream().collect(java.util.stream.Collectors.groupingBy(InboundFbaAppointmentItem::getAppointmentId));
|
||||
return appointments.stream().map(appointment -> response(appointment,
|
||||
groupedItems.getOrDefault(appointment.getId(), List.of()))).toList();
|
||||
}
|
||||
|
||||
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
|
||||
public FbaAppointmentResponse batchCreate(String operatorId,
|
||||
BatchFbaAppointmentRequest request) {
|
||||
List<Long> lineIds = List.copyOf(request.lineIds());
|
||||
if (new HashSet<>(lineIds).size() != lineIds.size()) {
|
||||
@@ -42,31 +65,37 @@ public class InboundFbaAppointmentService {
|
||||
String appointmentNo = requiredTrimmed(request.appointmentNo(), "Appointment number is required");
|
||||
String status = normalizeStatus(request.appointmentStatus());
|
||||
String remark = trimToNull(request.appointmentRemark());
|
||||
validateWindow(request.appointmentAt(), request.appointmentEndAt());
|
||||
|
||||
InboundFbaAppointment existing = find(customerId, appointmentNo);
|
||||
InboundFbaAppointment existing = find(appointmentNo);
|
||||
if (existing != null) {
|
||||
return verifyReplay(existing, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark);
|
||||
return verifyReplay(existing, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark, request.appointmentEndAt());
|
||||
}
|
||||
|
||||
List<FbaAppointmentLineSnapshot> lines = queryMapper.lockLines(customerId, lineIds);
|
||||
List<FbaAppointmentLineSnapshot> lines = queryMapper.lockLines(lineIds);
|
||||
if (lines.size() != lineIds.size()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"One or more selected inbound lines do not exist in this tenant");
|
||||
"所选入仓明细不存在,请刷新后重试");
|
||||
}
|
||||
String fbaWarehouseCode = commonFbaWarehouse(lines);
|
||||
|
||||
// The row locks serialize concurrent submissions selecting the same inbound lines.
|
||||
existing = find(customerId, appointmentNo);
|
||||
existing = find(appointmentNo);
|
||||
if (existing != null) {
|
||||
return verifyReplay(existing, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark);
|
||||
return verifyReplay(existing, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark, request.appointmentEndAt());
|
||||
}
|
||||
|
||||
if (queryMapper.countActiveAppointments(lineIds, null) > 0) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "所选货物已有有效预约,请编辑原预约或先取消");
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
InboundFbaAppointment appointment = new InboundFbaAppointment();
|
||||
appointment.setId(IdWorker.getId());
|
||||
appointment.setCustomerId(customerId);
|
||||
appointment.setAppointmentNo(appointmentNo);
|
||||
appointment.setAppointmentAt(request.appointmentAt());
|
||||
appointment.setAppointmentEndAt(request.appointmentEndAt());
|
||||
appointment.setVersion(0);
|
||||
appointment.setExpectedLoadingAt(request.expectedLoadingAt());
|
||||
appointment.setAppointmentStatus(status);
|
||||
appointment.setAppointmentRemark(remark);
|
||||
@@ -78,9 +107,9 @@ public class InboundFbaAppointmentService {
|
||||
try {
|
||||
appointmentMapper.insert(appointment);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
InboundFbaAppointment concurrent = find(customerId, appointmentNo);
|
||||
InboundFbaAppointment concurrent = find(appointmentNo);
|
||||
if (concurrent != null) {
|
||||
return verifyReplay(concurrent, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark);
|
||||
return verifyReplay(concurrent, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark, request.appointmentEndAt());
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
@@ -89,6 +118,7 @@ public class InboundFbaAppointmentService {
|
||||
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
||||
item.setId(IdWorker.getId());
|
||||
item.setAppointmentId(appointment.getId());
|
||||
item.setCustomerId(line.customerId());
|
||||
item.setPlanId(line.planId());
|
||||
item.setLineId(line.lineId());
|
||||
item.setOrderId(line.orderId());
|
||||
@@ -101,39 +131,75 @@ public class InboundFbaAppointmentService {
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public FbaAppointmentResponse detail(String customerId, String appointmentNo) {
|
||||
InboundFbaAppointment appointment = find(customerId,
|
||||
requiredTrimmed(appointmentNo, "Appointment number is required"));
|
||||
public FbaAppointmentResponse detail(String appointmentNo) {
|
||||
InboundFbaAppointment appointment = find(requiredTrimmed(appointmentNo, "Appointment number is required"));
|
||||
if (appointment == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "FBA appointment not found");
|
||||
}
|
||||
return response(appointment, items(appointment.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FbaAppointmentResponse update(String customerId, String appointmentNo,
|
||||
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
|
||||
public FbaAppointmentResponse update(String appointmentNo,
|
||||
UpdateFbaAppointmentRequest request) {
|
||||
InboundFbaAppointment appointment = find(customerId,
|
||||
requiredTrimmed(appointmentNo, "Appointment number is required"));
|
||||
InboundFbaAppointment appointment = find(requiredTrimmed(appointmentNo, "Appointment number is required"));
|
||||
if (appointment == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "FBA appointment not found");
|
||||
}
|
||||
String status = normalizeStatus(request.appointmentStatus());
|
||||
validateWindow(request.appointmentAt(), request.appointmentEndAt());
|
||||
if (request.version() == null || !Objects.equals(appointment.getVersion(), request.version())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "预约已被其他人修改,请重新打开后再保存");
|
||||
}
|
||||
List<Long> lineIds = items(appointment.getId()).stream().map(InboundFbaAppointmentItem::getLineId).sorted().toList();
|
||||
if (!lineIds.isEmpty()) {
|
||||
queryMapper.lockLines(lineIds);
|
||||
if (!Set.of("FAILED", "CANCELLED").contains(status)
|
||||
&& queryMapper.countActiveAppointments(lineIds, appointment.getId()) > 0) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "所选货物已有其他有效预约,不能恢复当前预约");
|
||||
}
|
||||
}
|
||||
appointment.setAppointmentAt(request.appointmentAt());
|
||||
appointment.setExpectedLoadingAt(request.expectedLoadingAt());
|
||||
appointment.setAppointmentStatus(normalizeStatus(request.appointmentStatus()));
|
||||
appointment.setAppointmentStatus(status);
|
||||
appointment.setAppointmentEndAt(request.appointmentEndAt());
|
||||
appointment.setVersion(request.version() + 1);
|
||||
appointment.setAppointmentRemark(trimToNull(request.appointmentRemark()));
|
||||
appointment.setUpdatedAt(LocalDateTime.now());
|
||||
appointmentMapper.updateById(appointment);
|
||||
if (appointmentMapper.update(appointment, new LambdaQueryWrapper<InboundFbaAppointment>()
|
||||
.eq(InboundFbaAppointment::getId, appointment.getId())
|
||||
.eq(InboundFbaAppointment::getVersion, request.version())) != 1) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "预约已被其他人修改,请重新打开后再保存");
|
||||
}
|
||||
return response(appointment, items(appointment.getId()));
|
||||
}
|
||||
|
||||
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
|
||||
public void delete(String appointmentNo, int version) {
|
||||
InboundFbaAppointment appointment = find(requiredTrimmed(appointmentNo, "预约编号不能为空"));
|
||||
if (appointment == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "预约不存在");
|
||||
List<Long> lineIds = items(appointment.getId()).stream().map(InboundFbaAppointmentItem::getLineId).sorted().toList();
|
||||
if (!lineIds.isEmpty()) queryMapper.lockLines(lineIds);
|
||||
if (queryMapper.countActiveDispatches(appointment.getId()) > 0) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "预约货物已关联排车,不允许删除,请先处理关联排车");
|
||||
}
|
||||
if (appointmentMapper.delete(new LambdaQueryWrapper<InboundFbaAppointment>()
|
||||
.eq(InboundFbaAppointment::getId, appointment.getId())
|
||||
.eq(InboundFbaAppointment::getVersion, version)) != 1) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "预约已被其他人修改,请刷新后重试");
|
||||
}
|
||||
itemMapper.delete(new LambdaQueryWrapper<InboundFbaAppointmentItem>()
|
||||
.eq(InboundFbaAppointmentItem::getAppointmentId, appointment.getId()));
|
||||
}
|
||||
|
||||
private FbaAppointmentResponse verifyReplay(InboundFbaAppointment existing, List<Long> requestedLineIds,
|
||||
LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt,
|
||||
String status, String remark) {
|
||||
String status, String remark, LocalDateTime appointmentEndAt) {
|
||||
List<InboundFbaAppointmentItem> existingItems = items(existing.getId());
|
||||
Set<Long> existingLineIds = existingItems.stream()
|
||||
.map(InboundFbaAppointmentItem::getLineId).collect(java.util.stream.Collectors.toSet());
|
||||
if (!Objects.equals(existing.getAppointmentAt(), appointmentAt)
|
||||
|| !Objects.equals(existing.getAppointmentEndAt(), appointmentEndAt)
|
||||
|| !Objects.equals(existing.getExpectedLoadingAt(), expectedLoadingAt)
|
||||
|| !Objects.equals(existing.getAppointmentStatus(), status)
|
||||
|| !Objects.equals(existing.getAppointmentRemark(), remark)
|
||||
@@ -155,10 +221,9 @@ public class InboundFbaAppointmentService {
|
||||
return warehouses.get(0);
|
||||
}
|
||||
|
||||
private InboundFbaAppointment find(String customerId, String appointmentNo) {
|
||||
private InboundFbaAppointment find(String appointmentNo) {
|
||||
LambdaQueryWrapper<InboundFbaAppointment> wrapper = new LambdaQueryWrapper<InboundFbaAppointment>()
|
||||
.eq(InboundFbaAppointment::getAppointmentNo, appointmentNo);
|
||||
if (customerId != null) wrapper.eq(InboundFbaAppointment::getCustomerId, customerId);
|
||||
return appointmentMapper.selectOne(wrapper);
|
||||
}
|
||||
|
||||
@@ -170,13 +235,20 @@ public class InboundFbaAppointmentService {
|
||||
|
||||
private FbaAppointmentResponse response(InboundFbaAppointment appointment,
|
||||
List<InboundFbaAppointmentItem> items) {
|
||||
return new FbaAppointmentResponse(appointment.getId(), appointment.getCustomerId(),
|
||||
return new FbaAppointmentResponse(appointment.getId(),
|
||||
appointment.getAppointmentNo(), appointment.getAppointmentAt(), appointment.getExpectedLoadingAt(),
|
||||
appointment.getAppointmentStatus(),
|
||||
appointment.getAppointmentRemark(), appointment.getFbaWarehouseCode(), appointment.getSelectedCount(),
|
||||
appointment.getCreatedBy(), appointment.getCreatedAt(), appointment.getUpdatedAt(),
|
||||
items.stream().map(item -> new FbaAppointmentResponse.Item(item.getId(), item.getPlanId(),
|
||||
item.getLineId(), item.getOrderId(), item.getInboundCode(), item.getFbaWarehouseCode())).toList());
|
||||
item.getLineId(), item.getOrderId(), item.getInboundCode(), item.getFbaWarehouseCode(), item.getCustomerId())).toList(),
|
||||
appointment.getAppointmentEndAt(), appointment.getVersion());
|
||||
}
|
||||
|
||||
private static void validateWindow(LocalDateTime start, LocalDateTime end) {
|
||||
if (start == null || (end != null && !end.isAfter(start))) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "预约结束时间必须晚于开始时间");
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeStatus(String value) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.security;
|
||||
|
||||
/** Canonical authorities for overseas browser APIs. */
|
||||
public final class OverseasPermissions {
|
||||
public static final String APPOINTMENT_LIST = "platform:appointment:list";
|
||||
public static final String LAST_MILE_ORDER_LIST = "platform:overseas-warehouse:last-mile-order:list";
|
||||
public static final String LAST_MILE_ORDER_EDIT = "platform:overseas-warehouse:last-mile-order:edit";
|
||||
public static final String LAST_MILE_ORDER_EXPORT = "platform:overseas-warehouse:last-mile-order:export";
|
||||
|
||||
@@ -1626,9 +1626,10 @@ CREATE TABLE IF NOT EXISTS ll_pda_inventory_move (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
|
||||
id BIGINT NOT NULL COMMENT '主键ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
appointment_no VARCHAR(64) NOT NULL COMMENT '预约单号',
|
||||
appointment_at DATETIME(3) NOT NULL COMMENT '预约时间',
|
||||
appointment_end_at DATETIME(3) NULL COMMENT '预约结束时间,未明确时为空',
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '并发版本',
|
||||
expected_loading_at DATETIME(3) NOT NULL COMMENT '预计装车时间',
|
||||
appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '预约状态',
|
||||
appointment_remark VARCHAR(1000) NULL COMMENT '预约备注',
|
||||
@@ -1638,13 +1639,14 @@ CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_fba_appointment_no (customer_id, appointment_no),
|
||||
KEY idx_ll_fba_appointment_list (customer_id, appointment_status, fba_warehouse_code, created_at)
|
||||
UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
|
||||
KEY idx_ll_fba_appointment_list (appointment_at, appointment_status, fba_warehouse_code)
|
||||
) ENGINE=InnoDB COMMENT='FBA入仓预约单';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item (
|
||||
id BIGINT NOT NULL COMMENT '主键ID',
|
||||
appointment_id BIGINT NOT NULL COMMENT '预约单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '本票货物客户ID',
|
||||
plan_id BIGINT NOT NULL COMMENT '入仓计划ID',
|
||||
line_id BIGINT NOT NULL COMMENT '入仓计划明细ID',
|
||||
order_id BIGINT NOT NULL COMMENT '订单ID',
|
||||
@@ -1653,36 +1655,11 @@ CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item (
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_fba_appointment_item_line (appointment_id, line_id),
|
||||
KEY idx_ll_fba_appointment_item_cargo (line_id,appointment_id),
|
||||
KEY idx_ll_fba_appointment_item_plan (plan_id),
|
||||
KEY idx_ll_fba_appointment_item_order (order_id)
|
||||
) ENGINE=InnoDB COMMENT='FBA入仓预约明细';
|
||||
|
||||
-- Upgrade the pre-customer-scope appointment table created by older deployments.
|
||||
SET @ddl = (SELECT IF(
|
||||
NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_inbound_fba_appointment' AND column_name='customer_id')
|
||||
AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_inbound_fba_appointment' AND column_name='tenant_id'),
|
||||
'ALTER TABLE ll_inbound_fba_appointment CHANGE COLUMN tenant_id customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''',
|
||||
'SELECT 1'));
|
||||
PREPARE fba_appointment_customer_scope FROM @ddl; EXECUTE fba_appointment_customer_scope; DEALLOCATE PREPARE fba_appointment_customer_scope;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_inbound_fba_appointment' AND column_name='expected_loading_at'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE ll_inbound_fba_appointment ADD COLUMN expected_loading_at DATETIME(3) NULL COMMENT ''预计装车时间'' AFTER appointment_at'));
|
||||
PREPARE fba_appointment_expected_loading FROM @ddl; EXECUTE fba_appointment_expected_loading; DEALLOCATE PREPARE fba_appointment_expected_loading;
|
||||
|
||||
UPDATE ll_inbound_fba_appointment
|
||||
SET expected_loading_at=appointment_at
|
||||
WHERE expected_loading_at IS NULL;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_inbound_fba_appointment' AND column_name='expected_loading_at' AND is_nullable='YES'),
|
||||
'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN expected_loading_at DATETIME(3) NOT NULL COMMENT ''预计装车时间''',
|
||||
'SELECT 1'));
|
||||
PREPARE fba_appointment_expected_loading_required FROM @ddl; EXECUTE fba_appointment_expected_loading_required; DEALLOCATE PREPARE fba_appointment_expected_loading_required;
|
||||
|
||||
ALTER TABLE ll_loading_order_status_history
|
||||
MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键',
|
||||
MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单 ID',
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 跨客户 FBA 预约与日历
|
||||
version: 1.0.0
|
||||
description: 按同一目的FBA仓合并多个客户的货物,预约号全局唯一。货物明细保留客户归属,只有平台可读取或维护完整预约;不改变库存与客户订单归属。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/platform/v1/last-leg/inbound-plans/fba-appointments/batch:
|
||||
post:
|
||||
summary: 为同一目的仓的跨客户入仓明细创建预约
|
||||
description: '权限:platform:overseas-warehouse:inbound-order:fba-appointment。平台身份限定。客户归属由服务端入仓计划确定,不接受预约主单customerId;同号同内容重放返回原预约,异内容拒绝。已有有效预约的货物不能重复建约。'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/AppointmentWrite'}
|
||||
- type: object
|
||||
required: [lineIds, appointmentNo]
|
||||
properties:
|
||||
lineIds: {type: array, minItems: 1, maxItems: 500, items: {type: string}}
|
||||
appointmentNo: {type: string, maxLength: 64}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/AppointmentResult'}
|
||||
'403': {description: 非平台身份或无写权限}
|
||||
/api/platform/v1/last-leg/inbound-plans/fba-appointments/{appointmentNo}:
|
||||
parameters:
|
||||
- name: appointmentNo
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: string}
|
||||
delete:
|
||||
summary: 删除未关联有效排车的预约
|
||||
description: '平台专用,权限platform:appointment:delete。校验当前版本后原子删除预约及预约明细,不删除客户货物。已有关联有效排车时拒绝。'
|
||||
parameters:
|
||||
- {name: version, in: query, required: true, schema: {type: integer, minimum: 0}}
|
||||
responses:
|
||||
'200': {description: 业务响应,成功code为字符串200;版本冲突或已关联排车返回业务错误}
|
||||
'403': {description: 无删除权限}
|
||||
get:
|
||||
summary: 读取完整跨客户预约
|
||||
description: '仅平台,权限:platform:appointment:list 或 platform:overseas-warehouse:inbound-order:list。客户只能经原订单/入仓单接口读取自己的货物,不开放整张预约。'
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/AppointmentResult'}
|
||||
'403': {description: 无读取权限}
|
||||
put:
|
||||
summary: 按版本更新预约
|
||||
description: '权限:platform:overseas-warehouse:inbound-order:fba-appointment。平台限定,版本冲突拒绝;取消不删除货物。恢复时重新校验货物是否已被其他有效预约占用。'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/AppointmentWrite'}
|
||||
- type: object
|
||||
required: [version]
|
||||
properties:
|
||||
version: {type: integer, minimum: 0}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/AppointmentResult'}
|
||||
'403': {description: 无写权限}
|
||||
/api/platform/v1/last-leg/inbound-plans/fba-appointments/calendar:
|
||||
get:
|
||||
operationId: fbaAppointmentCalendar
|
||||
summary: 按日期范围查询 FBA 预约
|
||||
description: '权限:platform:appointment:list。仅平台身份可读取跨客户预约。固定批量查询预约及关联明细,不逐预约查详情。'
|
||||
parameters:
|
||||
- name: from
|
||||
in: query
|
||||
required: true
|
||||
description: 包含该日期零点,以现有预约业务日期为准。
|
||||
schema: {type: string, format: date}
|
||||
- name: to
|
||||
in: query
|
||||
required: true
|
||||
description: 不包含该日期零点;必须晚于from,间隔不超过42天。
|
||||
schema: {type: string, format: date}
|
||||
responses:
|
||||
'200':
|
||||
description: 业务响应;成功code为字符串200,空区间返回空数组。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string}
|
||||
data:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/Appointment'}
|
||||
'401': {description: 未登录或会话失效}
|
||||
'403': {description: 无列表权限}
|
||||
components:
|
||||
responses:
|
||||
AppointmentResult:
|
||||
description: 业务响应,成功code为字符串200,业务冲突返回对应业务错误码。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string}
|
||||
data: {$ref: '#/components/schemas/Appointment'}
|
||||
securitySchemes:
|
||||
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
|
||||
schemas:
|
||||
AppointmentWrite:
|
||||
type: object
|
||||
required: [appointmentAt, expectedLoadingAt, appointmentStatus]
|
||||
properties:
|
||||
appointmentAt: {type: string, description: 预约开始日期时间}
|
||||
appointmentEndAt: {type: string, nullable: true, description: 明确结束时间时必须晚于开始时间;未知留空,不推算}
|
||||
expectedLoadingAt: {type: string}
|
||||
appointmentStatus: {type: string, enum: [PENDING, SUCCESS, FAILED, CANCELLED]}
|
||||
appointmentRemark: {type: string, maxLength: 1000, nullable: true}
|
||||
Appointment:
|
||||
type: object
|
||||
properties:
|
||||
id: {type: string, description: 内部标识,不作为展示编号}
|
||||
appointmentNo: {type: string}
|
||||
appointmentAt: {type: string, description: 业务日期时间,不含时区偏移}
|
||||
appointmentEndAt: {type: string, nullable: true}
|
||||
version: {type: integer, minimum: 0}
|
||||
expectedLoadingAt: {type: string}
|
||||
appointmentStatus: {type: string, enum: [PENDING, SUCCESS, FAILED, CANCELLED]}
|
||||
appointmentRemark: {type: string, nullable: true}
|
||||
fbaWarehouseCode: {type: string}
|
||||
selectedCount: {type: integer}
|
||||
createdBy: {type: string, nullable: true}
|
||||
createdAt: {type: string}
|
||||
updatedAt: {type: string}
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id: {type: string}
|
||||
customerId: {type: string, description: 本票货物客户归属,不在预约主单指定客户}
|
||||
planId: {type: string}
|
||||
lineId: {type: string}
|
||||
orderId: {type: string}
|
||||
inboundCode: {type: string}
|
||||
fbaWarehouseCode: {type: string}
|
||||
+5
-3
@@ -26,10 +26,12 @@ class InboundPlanSchemaContractTest {
|
||||
assertTrue(schema.contains("create table if not exists ll_inbound_pda_operation"));
|
||||
assertTrue(schema.contains("create table if not exists ll_inbound_pallet_merge"));
|
||||
assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment"));
|
||||
assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (customer_id, appointment_no)"));
|
||||
assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (appointment_no)"));
|
||||
assertTrue(schema.contains("expected_loading_at"));
|
||||
assertTrue(schema.contains("change column tenant_id customer_id"));
|
||||
assertTrue(schema.contains("set expected_loading_at=appointment_at"));
|
||||
String appointment = schema.substring(schema.indexOf("create table if not exists ll_inbound_fba_appointment ("), schema.indexOf("create table if not exists ll_inbound_fba_appointment_item ("));
|
||||
assertFalse(appointment.contains("customer_id"));
|
||||
String items = schema.substring(schema.indexOf("create table if not exists ll_inbound_fba_appointment_item ("));
|
||||
assertTrue(items.substring(0, items.indexOf("engine=innodb")).contains("customer_id varchar(128) not null"));
|
||||
assertTrue(schema.contains("planned_arrival_date"));
|
||||
assertTrue(schema.contains("creation_mode varchar(32)"));
|
||||
assertTrue(schema.contains("modify column creation_mode varchar(32)"));
|
||||
|
||||
+15
-4
@@ -53,14 +53,14 @@ class InboundPlanControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformAppointmentUpdateUsesExplicitCustomerScope() {
|
||||
UpdateFbaAppointmentRequest request = new UpdateFbaAppointmentRequest("100",
|
||||
void platformAppointmentUpdateUsesSharedAppointmentNumber() {
|
||||
UpdateFbaAppointmentRequest request = new UpdateFbaAppointmentRequest(
|
||||
LocalDateTime.of(2026, 8, 20, 10, 30), LocalDateTime.of(2026, 8, 21, 8, 0),
|
||||
"PENDING", "dock");
|
||||
"PENDING", "dock", null, 0);
|
||||
|
||||
controller.updateFbaAppointment(platform(), "APT-1", request);
|
||||
|
||||
verify(appointmentService).update("100", "APT-1", request);
|
||||
verify(appointmentService).update("APT-1", request);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -98,6 +98,17 @@ class InboundPlanControllerTest {
|
||||
org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantCannotReadOrChangeACrossCustomerAppointment() {
|
||||
var customer = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of());
|
||||
assertThatThrownBy(() -> controller.fbaAppointmentDetail(customer, "APT-1"))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
assertThatThrownBy(() -> controller.fbaAppointmentCalendar(customer,
|
||||
java.time.LocalDate.of(2026, 10, 1), java.time.LocalDate.of(2026, 11, 1)))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
verify(appointmentService, never()).detail(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
private static AuthenticatedUser platform() {
|
||||
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB",
|
||||
Set.of("platform:overseas-warehouse:inbound-order:list"));
|
||||
|
||||
+5
-1
@@ -23,11 +23,15 @@ class InboundPlanPermissionContractTest {
|
||||
Map<String, Method> methods = Arrays.stream(InboundPlanController.class.getDeclaredMethods())
|
||||
.collect(Collectors.toMap(Method::getName, method -> method));
|
||||
|
||||
for (String name : new String[] {"page", "detail", "fbaAppointmentDetail"}) {
|
||||
for (String name : new String[] {"page", "detail"}) {
|
||||
Method method = methods.get(name);
|
||||
assertEquals("hasAuthority('" + LIST + "')", method.getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals("Required authority: " + LIST, method.getAnnotation(Operation.class).description());
|
||||
}
|
||||
assertEquals("hasAnyAuthority('" + LIST + "','platform:appointment:list')",
|
||||
methods.get("fbaAppointmentDetail").getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals("hasAuthority('platform:appointment:list')",
|
||||
methods.get("fbaAppointmentCalendar").getAnnotation(PreAuthorize.class).value());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+19
-2
@@ -25,8 +25,8 @@ class InboundPlanDispatchStatusPersistenceTest {
|
||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,plan_no VARCHAR(50),customer_id VARCHAR(20),overseas_warehouse_id BIGINT,assigned_flag BOOLEAN,merge_required BOOLEAN,urgent_flag BOOLEAN,status VARCHAR(30),created_at TIMESTAMP,version INT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,order_id BIGINT,inbound_code VARCHAR(50),destination_warehouse_id VARCHAR(30),destination_warehouse_code VARCHAR(30),destination_warehouse_name VARCHAR(50),fba_warehouse_code VARCHAR(30),required_package_count INT,volume_cbm DECIMAL(18,3),planned_pallet_count INT)");
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(20),lx_code VARCHAR(50),order_type VARCHAR(30),overseas_warehouse_code VARCHAR(30),overseas_warehouse_name VARCHAR(50),customer_name VARCHAR(50))");
|
||||
db.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT,line_id BIGINT,appointment_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,customer_id VARCHAR(20),created_at TIMESTAMP,appointment_no VARCHAR(50),appointment_status VARCHAR(30),appointment_at TIMESTAMP,expected_loading_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT,line_id BIGINT,appointment_id BIGINT,customer_id VARCHAR(20))");
|
||||
db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,created_at TIMESTAMP,appointment_no VARCHAR(50),appointment_status VARCHAR(30),appointment_at TIMESTAMP,expected_loading_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(line_id BIGINT,picked_package_count INT)");
|
||||
db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))");
|
||||
db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))");
|
||||
@@ -78,6 +78,23 @@ class InboundPlanDispatchStatusPersistenceTest {
|
||||
assertThat(mapper.count("B", query(null))).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharedAppointmentAppearsForBothCustomersWithoutExposingOtherCustomersInboundRows() {
|
||||
db.update("INSERT INTO ll_inbound_plan VALUES(4,'LIP-2','B',90,TRUE,FALSE,FALSE,'RECEIVED',CURRENT_TIMESTAMP,0)");
|
||||
db.update("INSERT INTO ll_inbound_plan_line VALUES(5,4,6,'IN-2','91','ABE4','ABE4','ABE4',1,1,1)");
|
||||
db.update("INSERT INTO ll_order VALUES(6,'B','LX-2','FIRST_LEG','LA','洛杉矶仓','客户B')");
|
||||
db.update("INSERT INTO ll_inbound_fba_appointment VALUES(7,CURRENT_TIMESTAMP,'APT-SHARED','SUCCESS',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)");
|
||||
db.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(8,2,7,'A'),(9,5,7,'B')");
|
||||
assertThat(mapper.rows(null, query(null), 0, 20)).hasSize(2)
|
||||
.extracting(InboundPlanListResponse::appointmentNo).containsOnly("APT-SHARED");
|
||||
for (String customer : new String[]{"A", "B"}) {
|
||||
var rows = mapper.rows(customer, query(null), 0, 20);
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).customerId()).isEqualTo(customer);
|
||||
assertThat(rows.get(0).appointmentNo()).isEqualTo("APT-SHARED");
|
||||
}
|
||||
}
|
||||
|
||||
private void dispatch(long id, String owner, String status, String itemOwner) {
|
||||
db.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, owner, status);
|
||||
db.update("INSERT INTO ll_dispatch_item VALUES(?,?,?,3)", id, itemOwner, id);
|
||||
|
||||
+124
-23
@@ -19,6 +19,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@@ -46,49 +47,54 @@ class InboundFbaAppointmentServiceTest {
|
||||
ReflectionTestUtils.setField(service, "itemMapper", itemMapper);
|
||||
ReflectionTestUtils.setField(service, "queryMapper", queryMapper);
|
||||
lenient().when(itemMapper.selectList(any())).thenReturn(List.of());
|
||||
lenient().when(appointmentMapper.update(any(InboundFbaAppointment.class), any())).thenReturn(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsOneBatchForSelectedLinesAndFreezesFbaWarehouse() throws Exception {
|
||||
assertEquals(true, InboundFbaAppointmentService.class
|
||||
.getMethod("batchCreate", String.class, String.class, BatchFbaAppointmentRequest.class)
|
||||
.getMethod("batchCreate", String.class, BatchFbaAppointmentRequest.class)
|
||||
.getAnnotation(Transactional.class) != null);
|
||||
when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(
|
||||
when(queryMapper.lockLines(List.of(11L, 12L))).thenReturn(List.of(
|
||||
line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "ONT8")));
|
||||
|
||||
var result = service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1"));
|
||||
var result = service.batchCreate("7", request(List.of(11L, 12L), "APT-1"));
|
||||
|
||||
assertEquals("APT-1", result.appointmentNo());
|
||||
assertEquals("ONT8", result.fbaWarehouseCode());
|
||||
assertEquals(expectedLoadingAt, result.expectedLoadingAt());
|
||||
assertEquals(2, result.selectedCount());
|
||||
var captured = org.mockito.ArgumentCaptor.forClass(InboundFbaAppointmentItem.class);
|
||||
verify(itemMapper, org.mockito.Mockito.times(2)).insert(captured.capture());
|
||||
assertEquals(List.of("CUSTOMER-11", "CUSTOMER-12"), captured.getAllValues().stream()
|
||||
.map(InboundFbaAppointmentItem::getCustomerId).toList());
|
||||
verify(appointmentMapper).insert(any(InboundFbaAppointment.class));
|
||||
verify(itemMapper, org.mockito.Mockito.times(2)).insert(any(InboundFbaAppointmentItem.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMixedFbaWarehouses() {
|
||||
when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(
|
||||
when(queryMapper.lockLines(List.of(11L, 12L))).thenReturn(List.of(
|
||||
line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "LAX9")));
|
||||
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1")));
|
||||
() -> service.batchCreate("7", request(List.of(11L, 12L), "APT-1")));
|
||||
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingLineFromAnotherTenant() {
|
||||
when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(line(11L, "IN-11", "ONT8")));
|
||||
void rejectsMissingSelectedLine() {
|
||||
when(queryMapper.lockLines(List.of(11L, 12L))).thenReturn(List.of(line(11L, "IN-11", "ONT8")));
|
||||
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1")));
|
||||
() -> service.batchCreate("7", request(List.of(11L, 12L), "APT-1")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateLineIdsAreRejectedBeforeDatabaseAccess() {
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("100", "7", request(List.of(11L, 11L), "APT-1")));
|
||||
verify(queryMapper, never()).lockLines(any(), any());
|
||||
() -> service.batchCreate("7", request(List.of(11L, 11L), "APT-1")));
|
||||
verify(queryMapper, never()).lockLines(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -101,7 +107,7 @@ class InboundFbaAppointmentServiceTest {
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(existing);
|
||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||
|
||||
var result = service.batchCreate("100", "7", request(List.of(11L), "APT-1"));
|
||||
var result = service.batchCreate("7", request(List.of(11L), "APT-1"));
|
||||
|
||||
assertEquals(99L, result.id());
|
||||
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
|
||||
@@ -115,7 +121,7 @@ class InboundFbaAppointmentServiceTest {
|
||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("100", "7", request(List.of(12L), "APT-1")));
|
||||
() -> service.batchCreate("7", request(List.of(12L), "APT-1")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -124,17 +130,17 @@ class InboundFbaAppointmentServiceTest {
|
||||
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
||||
item.setLineId(11L);
|
||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||
BatchFbaAppointmentRequest changed = new BatchFbaAppointmentRequest("100", List.of(11L), "APT-1",
|
||||
appointmentAt, expectedLoadingAt.plusHours(1), "SUCCESS", "dock");
|
||||
BatchFbaAppointmentRequest changed = new BatchFbaAppointmentRequest(List.of(11L), "APT-1",
|
||||
appointmentAt, expectedLoadingAt.plusHours(1), "SUCCESS", "dock", null);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("100", "7", changed));
|
||||
() -> service.batchCreate("7", changed));
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stringSnowflakeLineIdsDeserializeWithoutPrecisionLoss() throws Exception {
|
||||
String json = "{\"customerId\":\"100\",\"lineIds\":[\"9007199254740993\"],"
|
||||
String json = "{\"lineIds\":[\"9007199254740993\"],"
|
||||
+ "\"appointmentNo\":\"APT-1\",\"appointmentAt\":\"2026-08-20T10:30:00\","
|
||||
+ "\"expectedLoadingAt\":\"2026-08-21T08:00:00\",\"appointmentStatus\":\"PENDING\"}";
|
||||
|
||||
@@ -151,28 +157,122 @@ class InboundFbaAppointmentServiceTest {
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(existing);
|
||||
LocalDateTime loadingAt = expectedLoadingAt.plusHours(2);
|
||||
|
||||
var result = service.update("100", "APT-1", new UpdateFbaAppointmentRequest(
|
||||
"100", appointmentAt.plusDays(1), loadingAt, "PENDING", "updated"));
|
||||
var result = service.update("APT-1", new UpdateFbaAppointmentRequest(
|
||||
appointmentAt.plusDays(1), loadingAt, "PENDING", "updated", null, 0));
|
||||
|
||||
assertEquals(loadingAt, result.expectedLoadingAt());
|
||||
assertEquals("PENDING", result.appointmentStatus());
|
||||
verify(appointmentMapper).updateById(existing);
|
||||
verify(appointmentMapper).update(org.mockito.ArgumentMatchers.eq(existing), any());
|
||||
verify(itemMapper, never()).insert(any(InboundFbaAppointmentItem.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preventsAssigningTheSameCargoToTwoActiveAppointments() {
|
||||
when(queryMapper.lockLines(List.of(11L))).thenReturn(List.of(line(11L, "IN-11", "ONT8")));
|
||||
when(queryMapper.countActiveAppointments(List.of(11L), null)).thenReturn(1L);
|
||||
var exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("7", request(List.of(11L), "SECOND-APT")));
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelledAppointmentCannotBeReactivatedAfterItsCargoWasBookedElsewhere() {
|
||||
InboundFbaAppointment existing = appointment(99L, "APT-1", "ONT8");
|
||||
existing.setAppointmentStatus("CANCELLED");
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(existing);
|
||||
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
||||
item.setLineId(11L);
|
||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||
when(queryMapper.countActiveAppointments(List.of(11L), 99L)).thenReturn(1L);
|
||||
assertThrows(ResponseStatusException.class, () -> service.update("APT-1",
|
||||
new UpdateFbaAppointmentRequest(appointmentAt, expectedLoadingAt, "SUCCESS", "", null, 0)));
|
||||
verify(queryMapper).lockLines(List.of(11L));
|
||||
verify(appointmentMapper, never()).update(any(InboundFbaAppointment.class), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnAppointmentWindowWhoseEndIsNotAfterItsStart() {
|
||||
var request = new BatchFbaAppointmentRequest(List.of(11L), "BAD-WINDOW", appointmentAt,
|
||||
expectedLoadingAt, "PENDING", "", appointmentAt.minusMinutes(1));
|
||||
assertThrows(ResponseStatusException.class, () -> service.batchCreate("7", request));
|
||||
verify(queryMapper, never()).lockLines(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsStaleEditBeforeWritingTheSharedAppointment() {
|
||||
var existing = appointment(99L, "APT-1", "ONT8");
|
||||
existing.setVersion(3);
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(existing);
|
||||
var request = new UpdateFbaAppointmentRequest(appointmentAt, expectedLoadingAt,
|
||||
"PENDING", "", appointmentAt.plusHours(2), 2);
|
||||
var error = assertThrows(ResponseStatusException.class, () -> service.update("APT-1", request));
|
||||
assertEquals(409, error.getStatusCode().value());
|
||||
verify(appointmentMapper, never()).update(any(InboundFbaAppointment.class), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cannotDeleteAnAppointmentWhoseGoodsAlreadyHaveAnActiveDispatch() {
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(appointment(99L, "APT-1", "ONT8"));
|
||||
when(queryMapper.countActiveDispatches(99L)).thenReturn(1L);
|
||||
assertThrows(ResponseStatusException.class, () -> service.delete("APT-1", 0));
|
||||
verify(appointmentMapper, never()).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
|
||||
verify(itemMapper, never()).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleDeleteKeepsAllCargoAssociations() {
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(appointment(99L, "APT-1", "ONT8"));
|
||||
when(appointmentMapper.delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(0);
|
||||
assertThrows(ResponseStatusException.class, () -> service.delete("APT-1", 0));
|
||||
verify(itemMapper, never()).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
|
||||
}
|
||||
|
||||
private BatchFbaAppointmentRequest request(List<Long> lineIds, String number) {
|
||||
return new BatchFbaAppointmentRequest("100", lineIds, number, appointmentAt, expectedLoadingAt,
|
||||
"SUCCESS", "dock");
|
||||
return new BatchFbaAppointmentRequest(lineIds, number, appointmentAt, expectedLoadingAt,
|
||||
"SUCCESS", "dock", null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void calendarRejectsInvalidOrUnboundedDateRangesBeforeQuerying() {
|
||||
LocalDate from = LocalDate.of(2026, 9, 1);
|
||||
for (LocalDate to : List.of(from, from.minusDays(1), from.plusDays(43))) {
|
||||
assertThrows(ResponseStatusException.class, () -> service.calendar(from, to));
|
||||
}
|
||||
verify(appointmentMapper, never()).selectList(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyCalendarDoesNotQueryAppointmentItems() {
|
||||
when(appointmentMapper.selectList(any())).thenReturn(List.of());
|
||||
assertEquals(List.of(), service.calendar(LocalDate.of(2026, 9, 1), LocalDate.of(2026, 10, 1)));
|
||||
verify(itemMapper, never()).selectList(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void calendarLoadsAllItemsInOneBatchAndKeepsAppointmentAssociation() {
|
||||
var first = appointment(99L, "APT-1", "ONT8");
|
||||
var second = appointment(100L, "APT-2", "LAX9");
|
||||
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
||||
item.setAppointmentId(100L);
|
||||
item.setLineId(11L);
|
||||
item.setInboundCode("IN-11");
|
||||
when(appointmentMapper.selectList(any())).thenReturn(List.of(first, second));
|
||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||
var result = service.calendar(LocalDate.of(2026, 9, 1), LocalDate.of(2026, 10, 1));
|
||||
assertEquals(2, result.size());
|
||||
assertEquals(0, result.get(0).items().size());
|
||||
assertEquals("IN-11", result.get(1).items().get(0).inboundCode());
|
||||
verify(itemMapper).selectList(any());
|
||||
}
|
||||
|
||||
private FbaAppointmentLineSnapshot line(Long id, String inboundCode, String fba) {
|
||||
return new FbaAppointmentLineSnapshot(id, 1000L, id + 100L, inboundCode, fba);
|
||||
return new FbaAppointmentLineSnapshot(id, 1000L, id + 100L, inboundCode, fba, "CUSTOMER-" + id);
|
||||
}
|
||||
|
||||
private InboundFbaAppointment appointment(Long id, String number, String fba) {
|
||||
InboundFbaAppointment value = new InboundFbaAppointment();
|
||||
value.setId(id);
|
||||
value.setCustomerId("100");
|
||||
value.setAppointmentNo(number);
|
||||
value.setAppointmentAt(appointmentAt);
|
||||
value.setExpectedLoadingAt(expectedLoadingAt);
|
||||
@@ -180,6 +280,7 @@ class InboundFbaAppointmentServiceTest {
|
||||
value.setAppointmentRemark("dock");
|
||||
value.setFbaWarehouseCode(fba);
|
||||
value.setSelectedCount(1);
|
||||
value.setVersion(0);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user