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}
|
||||
Reference in New Issue
Block a user