Test #1
@@ -8664,7 +8664,7 @@ USE baiyue_firstleg;
|
||||
CREATE TABLE IF NOT EXISTS fl_ocean_booking (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID',
|
||||
booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、SUBMITTED已提交、CONFIRMED已确认、RELEASED已放舱、FAILED失败、CHANGE_PENDING船期待变更、CANCELLED已取消',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT仅限创建事务内部、CONFIRMED已确认、RELEASED已放舱、CHANGE_PENDING船期待变更、CANCELLED已取消',
|
||||
customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照',
|
||||
customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照',
|
||||
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
|
||||
@@ -8688,7 +8688,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
|
||||
pending_estimated_sailing_at DATETIME(3) NULL COMMENT '待确认变更预计开船时间',
|
||||
pending_estimated_arrival_at DATETIME(3) NULL COMMENT '待确认变更预计到港时间',
|
||||
change_reason VARCHAR(1000) NULL COMMENT '船期变更原因',
|
||||
failure_reason VARCHAR(1000) NULL COMMENT '订舱失败原因',
|
||||
cancellation_reason VARCHAR(1000) NULL COMMENT '取消订舱原因',
|
||||
remark VARCHAR(1000) NULL COMMENT '平台内部备注',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
|
||||
@@ -8704,7 +8703,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
|
||||
KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at),
|
||||
CONSTRAINT chk_ocean_booking_customer CHECK (customer_id > 0),
|
||||
CONSTRAINT chk_ocean_booking_status CHECK (status IN
|
||||
('DRAFT','SUBMITTED','CONFIRMED','RELEASED','FAILED','CHANGE_PENDING','CANCELLED')),
|
||||
('DRAFT','CONFIRMED','RELEASED','CHANGE_PENDING','CANCELLED')),
|
||||
CONSTRAINT chk_ocean_booking_schedule CHECK
|
||||
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at),
|
||||
CONSTRAINT chk_ocean_booking_pending_schedule CHECK
|
||||
@@ -8806,11 +8805,9 @@ BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱或已取消订舱不可修改';
|
||||
END IF;
|
||||
IF OLD.status <> NEW.status AND NOT (
|
||||
(OLD.status='DRAFT' AND NEW.status IN ('SUBMITTED','CANCELLED'))
|
||||
OR (OLD.status='SUBMITTED' AND NEW.status IN ('CONFIRMED','FAILED','CANCELLED'))
|
||||
(OLD.status='DRAFT' AND NEW.status='CONFIRMED')
|
||||
OR (OLD.status='CONFIRMED' AND NEW.status IN ('RELEASED','CHANGE_PENDING','CANCELLED'))
|
||||
OR (OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','FAILED','CANCELLED'))
|
||||
OR (OLD.status='FAILED' AND NEW.status='CANCELLED')) THEN
|
||||
OR (OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','CANCELLED'))) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海运订舱状态迁移非法';
|
||||
END IF;
|
||||
END$$
|
||||
@@ -8848,7 +8845,7 @@ BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='箱型需求不属于当前订舱';
|
||||
END IF;
|
||||
IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) NOT IN
|
||||
('SUBMITTED','CONFIRMED','CHANGE_PENDING') THEN
|
||||
('CONFIRMED','CHANGE_PENDING') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='当前订舱状态不可分配柜次';
|
||||
END IF;
|
||||
END$$
|
||||
@@ -8875,9 +8872,6 @@ BEGIN
|
||||
WHEN 5102140 THEN 'platform:firstleg:ocean-booking:list'
|
||||
WHEN 5102141 THEN 'platform:firstleg:ocean-booking:list'
|
||||
WHEN 5102142 THEN 'platform:firstleg:ocean-booking:create'
|
||||
WHEN 5102143 THEN 'platform:firstleg:ocean-booking:edit'
|
||||
WHEN 5102144 THEN 'platform:firstleg:ocean-booking:submit'
|
||||
WHEN 5102145 THEN 'platform:firstleg:ocean-booking:confirm'
|
||||
WHEN 5102146 THEN 'platform:firstleg:ocean-booking:release'
|
||||
WHEN 5102147 THEN 'platform:firstleg:ocean-booking:cancel'
|
||||
WHEN 5102148 THEN 'platform:firstleg:ocean-booking:change'
|
||||
@@ -8900,9 +8894,6 @@ INSERT INTO iam_permission
|
||||
VALUES
|
||||
(5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list','订舱管理','/first-leg/ocean-bookings','GET',80,'ACTIVE',1,0),
|
||||
(5102142,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:create','创建订舱',NULL,'POST',20,'ACTIVE',1,0),
|
||||
(5102143,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:edit','编辑订舱',NULL,'PUT',30,'ACTIVE',1,0),
|
||||
(5102144,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:submit','提交订舱',NULL,'POST',40,'ACTIVE',1,0),
|
||||
(5102145,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:confirm','确认订舱',NULL,'POST',50,'ACTIVE',1,0),
|
||||
(5102146,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:release','确认放舱',NULL,'POST',60,'ACTIVE',1,0),
|
||||
(5102147,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:cancel','取消订舱',NULL,'POST',70,'ACTIVE',1,0),
|
||||
(5102148,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:change','变更船期',NULL,'POST',80,'ACTIVE',1,0),
|
||||
@@ -8916,6 +8907,9 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
|
||||
ON permission.tenant_id=0 AND permission.permission_code LIKE 'platform:firstleg:ocean-booking:%'
|
||||
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
|
||||
|
||||
DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102143,5102144,5102145);
|
||||
DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145);
|
||||
|
||||
USE baiyue_firstleg;
|
||||
|
||||
|
||||
@@ -12869,7 +12863,6 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'destination_po
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'destination_port_name', '目的港名称快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'estimated_arrival_at', '当前确认预计到港时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'estimated_sailing_at', '当前确认预计开船时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'failure_reason', '订舱失败原因');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'freight_term', '海运费支付条款快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'id', '海运订舱ID');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'pending_estimated_arrival_at', '待确认变更预计到港时间');
|
||||
@@ -12881,7 +12874,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'remark', '平
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'service_term', '运输服务条款快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'shipping_company_id', '船公司逻辑ID快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'shipping_company_name', '船公司名称快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'status', '订舱状态:DRAFT草稿、SUBMITTED已提交、CONFIRMED已确认、RELEASED已放舱、FAILED失败、CHANGE_PENDING船期待变更、CANCELLED已取消');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'status', '订舱状态:DRAFT仅限创建事务内部、CONFIRMED已确认、RELEASED已放舱、CHANGE_PENDING船期待变更、CANCELLED已取消');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'updated_at', '订舱记录最后更新时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'updated_by', '最后修改操作人ID');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'updated_by_name', '最后修改操作人姓名快照');
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@ public interface CustomerTransitStatisticsMapper {
|
||||
JOIN fl_ocean_booking b ON b.id = c.ocean_booking_id
|
||||
WHERE c.actual_sailing_at IS NOT NULL
|
||||
AND c.actual_warehouse_arrival_at IS NULL
|
||||
AND b.status NOT IN ('CANCELLED', 'FAILED')
|
||||
AND b.status <> 'CANCELLED'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fl_loading_plan p
|
||||
JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id
|
||||
@@ -43,7 +43,7 @@ public interface CustomerTransitStatisticsMapper {
|
||||
JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0
|
||||
WHERE c.actual_sailing_at IS NOT NULL
|
||||
AND c.actual_warehouse_arrival_at IS NULL
|
||||
AND b.status NOT IN ('CANCELLED', 'FAILED')
|
||||
AND b.status <> 'CANCELLED'
|
||||
AND i.account_id IS NOT NULL AND i.account_id > 0
|
||||
) x
|
||||
WHERE x.customer_id = #{customerId}
|
||||
|
||||
+1
-16
@@ -36,23 +36,11 @@ public class PlatformOceanBookingController {
|
||||
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(oceanBookingId)); }
|
||||
|
||||
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:create')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_CREATE", resourceType="OCEAN_BOOKING", description="创建海运订舱单", captureBefore=false)
|
||||
@BusinessAudit(event="OCEAN_BOOKING_CREATE", resourceType="OCEAN_BOOKING", description="登记已确认海运订舱", captureBefore=false)
|
||||
public ApiResponse<Detail> create(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.create(user.userId(), user.username(), key, request)); }
|
||||
@PutMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_UPDATE", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="编辑海运订舱草稿")
|
||||
public ApiResponse<Detail> updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateDraftRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); }
|
||||
@PostMapping("/{oceanBookingId}/submit") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:submit')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_SUBMIT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="提交海运订舱")
|
||||
public ApiResponse<Detail> submit(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.submit(oceanBookingId, user.userId(), user.username(), key, request)); }
|
||||
@PostMapping("/{oceanBookingId}/confirm") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:confirm')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_CONFIRM", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="确认海运订舱")
|
||||
public ApiResponse<Detail> confirm(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirm(oceanBookingId, user.userId(), user.username(), key, request)); }
|
||||
@PostMapping("/{oceanBookingId}/release") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:release')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_RELEASE", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="确认放舱")
|
||||
public ApiResponse<Detail> release(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ReleaseRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.release(oceanBookingId, user.userId(), user.username(), key, request)); }
|
||||
@PostMapping("/{oceanBookingId}/fail") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:confirm')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_FAIL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="标记海运订舱失败")
|
||||
public ApiResponse<Detail> fail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.fail(oceanBookingId, user.userId(), user.username(), key, request)); }
|
||||
@PostMapping("/{oceanBookingId}/cancel") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:cancel')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_CANCEL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="取消海运订舱")
|
||||
public ApiResponse<Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.cancel(oceanBookingId, user.userId(), user.username(), key, request)); }
|
||||
@@ -62,8 +50,5 @@ public class PlatformOceanBookingController {
|
||||
@PostMapping("/{oceanBookingId}/change-confirm") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:change')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_CHANGE_CONFIRM", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="确认变更船期")
|
||||
public ApiResponse<Detail> confirmChange(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirmChange(oceanBookingId, user.userId(), user.username(), key, request)); }
|
||||
@PostMapping("/{oceanBookingId}/change-fail") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:change')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_CHANGE_FAIL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="标记变更船期失败")
|
||||
public ApiResponse<Detail> failChange(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.failChange(oceanBookingId, user.userId(), user.username(), key, request)); }
|
||||
private static void validateOptionalCustomer(AuthenticatedUser user, Long customerId) { FirstlegScopeGuard.platform(user); if (customerId != null) FirstlegScopeGuard.platform(user, customerId); }
|
||||
}
|
||||
|
||||
@@ -2,10 +2,8 @@ package com.baiyue.firstleg.oceanbooking.domain;
|
||||
|
||||
public enum OceanBookingStatus {
|
||||
DRAFT,
|
||||
SUBMITTED,
|
||||
CONFIRMED,
|
||||
RELEASED,
|
||||
FAILED,
|
||||
CHANGE_PENDING,
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
@@ -44,13 +44,13 @@ public final class OceanBookingDtos {
|
||||
@NotBlank @Size(max = 64) String channelCode,
|
||||
@NotNull @Positive Long warehouseId,
|
||||
@NotBlank @Size(max = 128) String warehouseName,
|
||||
@Size(max = 128) String billOfLadingNo,
|
||||
@NotBlank @Size(max = 128) String billOfLadingNo,
|
||||
@Positive Long shippingCompanyId,
|
||||
@Size(max = 128) String shippingCompanyName,
|
||||
@Positive Long bookingAgentId,
|
||||
@Size(max = 128) String bookingAgentName,
|
||||
@Size(max = 128) String vesselName,
|
||||
@Size(max = 64) String voyageNo,
|
||||
@NotBlank @Size(max = 128) String vesselName,
|
||||
@NotBlank @Size(max = 64) String voyageNo,
|
||||
@Size(max = 32) String departurePortCode,
|
||||
@Size(max = 128) String departurePortName,
|
||||
@Size(max = 32) String destinationPortCode,
|
||||
@@ -58,16 +58,14 @@ public final class OceanBookingDtos {
|
||||
@Size(max = 128) String placeOfDelivery,
|
||||
@NotBlank @Size(max = 32) String serviceTerm,
|
||||
@NotBlank @Size(max = 32) String freightTerm,
|
||||
LocalDateTime estimatedSailingAt,
|
||||
LocalDateTime estimatedArrivalAt,
|
||||
@NotNull LocalDateTime estimatedSailingAt,
|
||||
@NotNull LocalDateTime estimatedArrivalAt,
|
||||
@Size(max = 1000) String remark,
|
||||
@Valid @NotEmpty @Size(max = 3) List<PartyRequest> parties,
|
||||
@Valid @NotEmpty @Size(max = 20) List<ContainerRequirementRequest> containerRequirements) {}
|
||||
|
||||
public record CreateRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@Valid @NotNull DraftData data) {}
|
||||
public record UpdateDraftRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@Valid @NotNull DraftData data) {}
|
||||
public record ActionRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@Size(max = 1000) String reason) {}
|
||||
public record ReleaseRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
|
||||
@@ -45,7 +45,6 @@ public class OceanBookingEntity {
|
||||
private LocalDateTime pendingEstimatedSailingAt;
|
||||
private LocalDateTime pendingEstimatedArrivalAt;
|
||||
private String changeReason;
|
||||
private String failureReason;
|
||||
private String cancellationReason;
|
||||
private String remark;
|
||||
private Long createdBy;
|
||||
|
||||
+2
-20
@@ -30,31 +30,13 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
|
||||
Page<OceanBookingEntity> selectPage(Page<OceanBookingEntity> page, @Param("query") Query query);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},
|
||||
channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},
|
||||
bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},
|
||||
shipping_company_name=#{value.shippingCompanyName},booking_agent_id=#{value.bookingAgentId},
|
||||
booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},
|
||||
departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},
|
||||
destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},
|
||||
place_of_delivery=#{value.placeOfDelivery},service_term=#{value.serviceTerm},freight_term=#{value.freightTerm},
|
||||
estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},
|
||||
remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},
|
||||
updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE id=#{value.id} AND status='DRAFT' AND version=#{expectedVersion}
|
||||
""")
|
||||
int updateDraft(@Param("value") OceanBookingEntity value, @Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_ocean_booking SET status=#{toStatus},failure_reason=#{failureReason},
|
||||
UPDATE fl_ocean_booking SET status=#{toStatus},
|
||||
cancellation_reason=#{cancellationReason},updated_by=#{operatorId},updated_by_name=#{operatorName},
|
||||
updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE id=#{oceanBookingId} AND status=#{fromStatus} AND version=#{expectedVersion}
|
||||
""")
|
||||
int transition(@Param("oceanBookingId") long oceanBookingId, @Param("fromStatus") String fromStatus,
|
||||
@Param("toStatus") String toStatus, @Param("failureReason") String failureReason,
|
||||
@Param("cancellationReason") String cancellationReason,
|
||||
@Param("toStatus") String toStatus, @Param("cancellationReason") String cancellationReason,
|
||||
@Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
|
||||
+2
-5
@@ -28,12 +28,9 @@ public class OceanBookingRepository {
|
||||
}
|
||||
public OceanBookingEntity find(long id) { return bookings.selectById(id); }
|
||||
public void insert(OceanBookingEntity value) { bookings.insert(value); }
|
||||
public int updateDraft(OceanBookingEntity value, int version, long operatorId, String operatorName) {
|
||||
return bookings.updateDraft(value, version, operatorId, operatorName);
|
||||
}
|
||||
public int transition(long id, String from, String to, String failureReason, String cancellationReason,
|
||||
public int transition(long id, String from, String to, String cancellationReason,
|
||||
int version, long operatorId, String operatorName) {
|
||||
return bookings.transition(id, from, to, failureReason, cancellationReason, version, operatorId, operatorName);
|
||||
return bookings.transition(id, from, to, cancellationReason, version, operatorId, operatorName);
|
||||
}
|
||||
public int release(long id, ReleaseRequest request, long operatorId, String operatorName) {
|
||||
return bookings.release(id, request.billOfLadingNo().trim(), request.vesselName().trim(),
|
||||
|
||||
+29
-100
@@ -67,6 +67,7 @@ public class OceanBookingService {
|
||||
public Detail create(long operatorId, String operatorName, String key, CreateRequest request) {
|
||||
if (request.expectedVersion() != 0) throw concurrent();
|
||||
validateDraftStructure(request.data());
|
||||
validateRange(request.data().estimatedSailingAt(), request.data().estimatedArrivalAt());
|
||||
String action = "CREATE";
|
||||
String hash = hash(action, null, request);
|
||||
Detail replay = claimOrReplay(null, operatorId, action, key, hash);
|
||||
@@ -75,6 +76,8 @@ public class OceanBookingService {
|
||||
OceanBookingEntity value = new OceanBookingEntity();
|
||||
value.setId(IdWorker.getId());
|
||||
value.setBookingNo("OB" + value.getId());
|
||||
// Child-row triggers still require DRAFT while the aggregate is assembled.
|
||||
// The status transition is in the same transaction, so callers only observe CONFIRMED.
|
||||
value.setStatus(OceanBookingStatus.DRAFT.name());
|
||||
copy(value, request.data(), channel.channelName());
|
||||
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||
@@ -82,46 +85,16 @@ public class OceanBookingService {
|
||||
repository.insert(value);
|
||||
repository.replaceParties(value.getId(), parties(value.getId(), request.data().parties()));
|
||||
repository.replaceRequirements(value.getId(), requirements(value.getId(), request.data().containerRequirements()));
|
||||
addHistory(value.getId(), null, OceanBookingStatus.DRAFT.name(), action, null, null, value, operatorId, operatorName);
|
||||
return complete(value.getId(), operatorId, action, key, detail(value.getId()));
|
||||
if (repository.transition(value.getId(), OceanBookingStatus.DRAFT.name(),
|
||||
OceanBookingStatus.CONFIRMED.name(), null, 0, operatorId, operatorName) != 1) {
|
||||
throw concurrent();
|
||||
}
|
||||
OceanBookingEntity confirmed = required(value.getId());
|
||||
addHistory(value.getId(), OceanBookingStatus.DRAFT.name(), OceanBookingStatus.CONFIRMED.name(), action,
|
||||
null, value, confirmed, operatorId, operatorName);
|
||||
return complete(value.getId(), operatorId, action, key, detail(confirmed));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Detail updateDraft(long oceanBookingId, long operatorId, String operatorName, String key,
|
||||
UpdateDraftRequest request) {
|
||||
validateDraftStructure(request.data());
|
||||
String action = "UPDATE_DRAFT";
|
||||
Detail replay = claimOrReplay(oceanBookingId, operatorId, action, key, hash(action, oceanBookingId, request));
|
||||
if (replay != null) return replay;
|
||||
OceanBookingEntity before = requiredVersion(oceanBookingId, request.expectedVersion());
|
||||
requireStatus(before, OceanBookingStatus.DRAFT);
|
||||
ChannelOption channel = requireActiveChannel(request.data().warehouseId(), request.data().channelCode());
|
||||
OceanBookingEntity value = new OceanBookingEntity(); value.setId(oceanBookingId); copy(value, request.data(), channel.channelName());
|
||||
if (repository.updateDraft(value, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent();
|
||||
repository.replaceParties(oceanBookingId, parties(oceanBookingId, request.data().parties()));
|
||||
repository.replaceRequirements(oceanBookingId, requirements(oceanBookingId, request.data().containerRequirements()));
|
||||
return complete(oceanBookingId, operatorId, action, key, detail(oceanBookingId));
|
||||
}
|
||||
|
||||
@Transactional public Detail submit(long id, long operatorId, String name, String key, ActionRequest request) {
|
||||
return transition(id, operatorId, name, key, "SUBMIT", OceanBookingStatus.DRAFT,
|
||||
OceanBookingStatus.SUBMITTED, request, false);
|
||||
}
|
||||
@Transactional public Detail confirm(long id, long operatorId, String name, String key, ActionRequest request) {
|
||||
String action = "CONFIRM";
|
||||
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
|
||||
if (replay != null) return replay;
|
||||
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
|
||||
requireStatus(before, OceanBookingStatus.SUBMITTED);
|
||||
requireActiveChannel(before.getWarehouseId(), before.getChannelCode());
|
||||
validateConfirmation(before, repository.requirements(id));
|
||||
if (repository.transition(id, OceanBookingStatus.SUBMITTED.name(), OceanBookingStatus.CONFIRMED.name(),
|
||||
null, null, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
|
||||
OceanBookingEntity after = required(id);
|
||||
addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after,
|
||||
operatorId, name);
|
||||
return complete(id, operatorId, action, key, detail(after));
|
||||
}
|
||||
@Transactional public Detail release(long id, long operatorId, String name, String key, ReleaseRequest request) {
|
||||
validateRange(request.estimatedSailingAt(), request.estimatedArrivalAt());
|
||||
String action = "RELEASE";
|
||||
@@ -129,6 +102,7 @@ public class OceanBookingService {
|
||||
if (replay != null) return replay;
|
||||
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
|
||||
requireStatus(before, OceanBookingStatus.CONFIRMED);
|
||||
validateRegisteredTransport(before, request);
|
||||
if (repository.release(id, request, operatorId, name) != 1) throw concurrent();
|
||||
repository.syncReleasedTransport(id, request, operatorId, name);
|
||||
repository.allocations(id).forEach(allocation ->
|
||||
@@ -139,28 +113,6 @@ public class OceanBookingService {
|
||||
return complete(id, operatorId, action, key, detail(after));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Detail fail(long id, long operatorId, String name, String key, ActionRequest request) {
|
||||
if (!StringUtils.hasText(request.reason())) throw badRequest("失败原因不能为空");
|
||||
String action = "FAIL";
|
||||
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
|
||||
if (replay != null) return replay;
|
||||
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
|
||||
requireStatus(before, OceanBookingStatus.SUBMITTED);
|
||||
if (repository.transition(id, OceanBookingStatus.SUBMITTED.name(), OceanBookingStatus.FAILED.name(), trim(request.reason()), null,
|
||||
request.expectedVersion(), operatorId, name) != 1) throw concurrent();
|
||||
OceanBookingEntity after = required(id);
|
||||
addHistory(id, OceanBookingStatus.SUBMITTED.name(), after.getStatus(), action, request.reason(), before, after, operatorId, name);
|
||||
return complete(id, operatorId, action, key, detail(after));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Detail failChange(long id, long operatorId, String name, String key, ActionRequest request) {
|
||||
if (!StringUtils.hasText(request.reason())) throw badRequest("改船失败原因不能为空");
|
||||
return transition(id, operatorId, name, key, "FAIL_CHANGE", OceanBookingStatus.CHANGE_PENDING,
|
||||
OceanBookingStatus.FAILED, request, true);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Detail cancel(long id, long operatorId, String name, String key, ActionRequest request) {
|
||||
if (!StringUtils.hasText(request.reason())) throw badRequest("取消原因不能为空");
|
||||
@@ -172,7 +124,7 @@ public class OceanBookingService {
|
||||
if (from == OceanBookingStatus.RELEASED || from == OceanBookingStatus.CANCELLED) {
|
||||
throw conflict("订舱已越过不可逆节点或已经取消");
|
||||
}
|
||||
if (repository.transition(id, from.name(), OceanBookingStatus.CANCELLED.name(), null,
|
||||
if (repository.transition(id, from.name(), OceanBookingStatus.CANCELLED.name(),
|
||||
trim(request.reason()), request.expectedVersion(), operatorId, name) != 1) throw concurrent();
|
||||
OceanBookingEntity after = required(id);
|
||||
addHistory(id, from.name(), after.getStatus(), action, request.reason(), before, after, operatorId, name);
|
||||
@@ -211,20 +163,6 @@ public class OceanBookingService {
|
||||
return complete(id, operatorId, action, key, detail(after));
|
||||
}
|
||||
|
||||
private Detail transition(long id, long operatorId, String name, String key, String action,
|
||||
OceanBookingStatus from, OceanBookingStatus to, ActionRequest request,
|
||||
boolean failure) {
|
||||
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
|
||||
if (replay != null) return replay;
|
||||
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
|
||||
requireStatus(before, from);
|
||||
if (from == OceanBookingStatus.DRAFT && to == OceanBookingStatus.SUBMITTED) validateSubmission(id);
|
||||
if (repository.transition(id, from.name(), to.name(), failure ? trim(request.reason()) : null, null,
|
||||
request.expectedVersion(), operatorId, name) != 1) throw concurrent();
|
||||
OceanBookingEntity after = required(id);
|
||||
addHistory(id, from.name(), to.name(), action, request.reason(), before, after, operatorId, name);
|
||||
return complete(id, operatorId, action, key, detail(after));
|
||||
}
|
||||
|
||||
private Detail claimOrReplay(Long id, long operatorId, String action, String key, String hash) {
|
||||
validateKey(key);
|
||||
@@ -266,10 +204,10 @@ public class OceanBookingService {
|
||||
if (!StringUtils.hasText(data.serviceTerm()) || !StringUtils.hasText(data.freightTerm())) {
|
||||
throw badRequest("Service Term和Freight Term不能为空");
|
||||
}
|
||||
if (data.estimatedSailingAt() != null || data.estimatedArrivalAt() != null
|
||||
|| StringUtils.hasText(data.billOfLadingNo()) || StringUtils.hasText(data.vesselName())
|
||||
|| StringUtils.hasText(data.voyageNo())) {
|
||||
throw badRequest("真实运输数据请在确认放舱时录入");
|
||||
if (!StringUtils.hasText(data.billOfLadingNo()) || !StringUtils.hasText(data.vesselName())
|
||||
|| !StringUtils.hasText(data.voyageNo()) || data.estimatedSailingAt() == null
|
||||
|| data.estimatedArrivalAt() == null) {
|
||||
throw badRequest("提单号、船名、航次、ETD和ETA不能为空");
|
||||
}
|
||||
Set<String> types = new HashSet<>();
|
||||
for (PartyRequest party : data.parties()) {
|
||||
@@ -278,14 +216,16 @@ public class OceanBookingService {
|
||||
}
|
||||
if (!types.equals(PARTY_TYPES)) throw badRequest("订舱方必须各包含一条SHIPPER、CONSIGNEE和NOTIFY_PARTY");
|
||||
}
|
||||
private void validateSubmission(long id) {
|
||||
Set<String> types = repository.parties(id).stream().map(OceanBookingPartyEntity::getPartyType)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
if (!types.equals(PARTY_TYPES) || repository.requirements(id).isEmpty()) {
|
||||
throw conflict("订舱方或箱型需求不完整,不能提交");
|
||||
private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); }
|
||||
private static void validateRegisteredTransport(OceanBookingEntity booking, ReleaseRequest request) {
|
||||
if (!java.util.Objects.equals(trim(booking.getBillOfLadingNo()), trim(request.billOfLadingNo()))
|
||||
|| !java.util.Objects.equals(trim(booking.getVesselName()), trim(request.vesselName()))
|
||||
|| !java.util.Objects.equals(trim(booking.getVoyageNo()), trim(request.voyageNo()))
|
||||
|| !java.util.Objects.equals(booking.getEstimatedSailingAt(), request.estimatedSailingAt())
|
||||
|| !java.util.Objects.equals(booking.getEstimatedArrivalAt(), request.estimatedArrivalAt())) {
|
||||
throw conflict("放舱运输字段必须与已登记的提单、船名、航次、ETD和ETA一致");
|
||||
}
|
||||
}
|
||||
private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); }
|
||||
private ChannelOption requireActiveChannel(Long originWarehouseId, String channelCode) {
|
||||
if (originWarehouseId == null || originWarehouseId <= 0) throw badRequest("请选择有效起运仓");
|
||||
String normalized = normalizeChannel(channelCode);
|
||||
@@ -312,26 +252,15 @@ public class OceanBookingService {
|
||||
v.setCustomerId(d.customerId()); v.setCustomerName(trim(d.customerName()));
|
||||
v.setChannelCode(d.channelCode().trim()); v.setChannelName(resolvedChannelName);
|
||||
v.setWarehouseId(d.warehouseId()); v.setWarehouseName(d.warehouseName().trim());
|
||||
v.setBillOfLadingNo(null);
|
||||
v.setBillOfLadingNo(d.billOfLadingNo().trim());
|
||||
v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(trim(d.shippingCompanyName()));
|
||||
v.setBookingAgentId(d.bookingAgentId()); v.setBookingAgentName(trim(d.bookingAgentName()));
|
||||
v.setVesselName(null); v.setVoyageNo(null);
|
||||
v.setVesselName(d.vesselName().trim()); v.setVoyageNo(d.voyageNo().trim());
|
||||
v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName()));
|
||||
v.setDestinationPortCode(trim(d.destinationPortCode())); v.setDestinationPortName(trim(d.destinationPortName()));
|
||||
v.setPlaceOfDelivery(trim(d.placeOfDelivery())); v.setServiceTerm(trim(d.serviceTerm())); v.setFreightTerm(trim(d.freightTerm()));
|
||||
v.setEstimatedSailingAt(null); v.setEstimatedArrivalAt(null); v.setRemark(trim(d.remark()));
|
||||
}
|
||||
|
||||
private static void validateConfirmation(OceanBookingEntity booking,
|
||||
List<OceanBookingContainerRequirementEntity> requirements) {
|
||||
if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(booking.getChannelName())
|
||||
|| booking.getWarehouseId() == null || !StringUtils.hasText(booking.getWarehouseName())) {
|
||||
throw conflict("订舱缺少渠道或国内中转仓快照,不能确认");
|
||||
}
|
||||
if (requirements.isEmpty() || requirements.stream().anyMatch(requirement ->
|
||||
requirement.getCapacityCbm() == null || requirement.getMaxWeightKg() == null)) {
|
||||
throw conflict("箱型需求缺少容量或最大承重快照,不能确认");
|
||||
}
|
||||
v.setEstimatedSailingAt(d.estimatedSailingAt()); v.setEstimatedArrivalAt(d.estimatedArrivalAt());
|
||||
v.setRemark(trim(d.remark()));
|
||||
}
|
||||
|
||||
private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); return v; }).toList(); }
|
||||
|
||||
@@ -4,7 +4,7 @@ USE baiyue_firstleg;
|
||||
CREATE TABLE IF NOT EXISTS fl_ocean_booking (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID',
|
||||
booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、SUBMITTED已提交、CONFIRMED已确认、RELEASED已放舱、FAILED失败、CHANGE_PENDING船期待变更、CANCELLED已取消',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT仅限创建事务内部、CONFIRMED已确认、RELEASED已放舱、CHANGE_PENDING船期待变更、CANCELLED已取消',
|
||||
customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照',
|
||||
customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照',
|
||||
channel_code VARCHAR(64) NOT NULL COMMENT '订舱渠道编码快照',
|
||||
@@ -32,7 +32,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
|
||||
pending_estimated_sailing_at DATETIME(3) NULL COMMENT '待确认变更预计开船时间',
|
||||
pending_estimated_arrival_at DATETIME(3) NULL COMMENT '待确认变更预计到港时间',
|
||||
change_reason VARCHAR(1000) NULL COMMENT '船期变更原因',
|
||||
failure_reason VARCHAR(1000) NULL COMMENT '订舱失败原因',
|
||||
cancellation_reason VARCHAR(1000) NULL COMMENT '取消订舱原因',
|
||||
remark VARCHAR(1000) NULL COMMENT '平台内部备注',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
|
||||
@@ -48,7 +47,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
|
||||
KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at),
|
||||
CONSTRAINT chk_ocean_booking_customer CHECK (customer_id > 0),
|
||||
CONSTRAINT chk_ocean_booking_status CHECK (status IN
|
||||
('DRAFT','SUBMITTED','CONFIRMED','RELEASED','FAILED','CHANGE_PENDING','CANCELLED')),
|
||||
('DRAFT','CONFIRMED','RELEASED','CHANGE_PENDING','CANCELLED')),
|
||||
CONSTRAINT chk_ocean_booking_schedule CHECK
|
||||
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at),
|
||||
CONSTRAINT chk_ocean_booking_pending_schedule CHECK
|
||||
@@ -150,11 +149,9 @@ BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱或已取消订舱不可修改';
|
||||
END IF;
|
||||
IF OLD.status <> NEW.status AND NOT (
|
||||
(OLD.status='DRAFT' AND NEW.status IN ('SUBMITTED','CANCELLED'))
|
||||
OR (OLD.status='SUBMITTED' AND NEW.status IN ('CONFIRMED','FAILED','CANCELLED'))
|
||||
(OLD.status='DRAFT' AND NEW.status='CONFIRMED')
|
||||
OR (OLD.status='CONFIRMED' AND NEW.status IN ('RELEASED','CHANGE_PENDING','CANCELLED'))
|
||||
OR (OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','FAILED','CANCELLED'))
|
||||
OR (OLD.status='FAILED' AND NEW.status='CANCELLED')) THEN
|
||||
OR (OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','CANCELLED'))) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海运订舱状态迁移非法';
|
||||
END IF;
|
||||
END$$
|
||||
@@ -192,7 +189,7 @@ BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='箱型需求不属于当前订舱';
|
||||
END IF;
|
||||
IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) NOT IN
|
||||
('SUBMITTED','CONFIRMED','CHANGE_PENDING') THEN
|
||||
('CONFIRMED','CHANGE_PENDING') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='当前订舱状态不可分配柜次';
|
||||
END IF;
|
||||
END$$
|
||||
@@ -219,9 +216,6 @@ BEGIN
|
||||
WHEN 5102140 THEN 'platform:firstleg:ocean-booking:list'
|
||||
WHEN 5102141 THEN 'platform:firstleg:ocean-booking:list'
|
||||
WHEN 5102142 THEN 'platform:firstleg:ocean-booking:create'
|
||||
WHEN 5102143 THEN 'platform:firstleg:ocean-booking:edit'
|
||||
WHEN 5102144 THEN 'platform:firstleg:ocean-booking:submit'
|
||||
WHEN 5102145 THEN 'platform:firstleg:ocean-booking:confirm'
|
||||
WHEN 5102146 THEN 'platform:firstleg:ocean-booking:release'
|
||||
WHEN 5102147 THEN 'platform:firstleg:ocean-booking:cancel'
|
||||
WHEN 5102148 THEN 'platform:firstleg:ocean-booking:change'
|
||||
@@ -244,9 +238,6 @@ INSERT INTO iam_permission
|
||||
VALUES
|
||||
(5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list','订舱管理','/first-leg/ocean-bookings','GET',80,'ACTIVE',1,0),
|
||||
(5102142,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:create','创建订舱',NULL,'POST',20,'ACTIVE',1,0),
|
||||
(5102143,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:edit','编辑订舱',NULL,'PUT',30,'ACTIVE',1,0),
|
||||
(5102144,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:submit','提交订舱',NULL,'POST',40,'ACTIVE',1,0),
|
||||
(5102145,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:confirm','确认订舱',NULL,'POST',50,'ACTIVE',1,0),
|
||||
(5102146,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:release','确认放舱',NULL,'POST',60,'ACTIVE',1,0),
|
||||
(5102147,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:cancel','取消订舱',NULL,'POST',70,'ACTIVE',1,0),
|
||||
(5102148,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:change','变更船期',NULL,'POST',80,'ACTIVE',1,0),
|
||||
@@ -263,5 +254,7 @@ WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
|
||||
DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id
|
||||
WHERE p.id=5102141;
|
||||
UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102141;
|
||||
DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102143,5102144,5102145);
|
||||
DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145);
|
||||
|
||||
USE baiyue_firstleg;
|
||||
|
||||
@@ -4,14 +4,14 @@ paths:
|
||||
/api/platform/v1/ocean-bookings/channels:
|
||||
get:
|
||||
summary: List logistics channels from the current ACTIVE price book for an origin warehouse
|
||||
description: 'Requires platform:firstleg:ocean-booking:list. Returns only channels with an ACTIVE price row; the selected channel name is frozen server-side on draft save.'
|
||||
description: 'Requires platform:firstleg:ocean-booking:list. Returns only channels with an ACTIVE price row; the selected channel name is frozen server-side when the offline-confirmed booking is registered.'
|
||||
parameters:
|
||||
- {name: originWarehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
responses: {'200': {description: Available channel options, content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/ChannelOption'}}}}}}
|
||||
/api/platform/v1/ocean-bookings:
|
||||
get:
|
||||
summary: List only real confirmed booking records
|
||||
description: 'Requires platform:firstleg:ocean-booking:list. The operational list exposes only CONFIRMED, CHANGE_PENDING, RELEASED and CANCELLED bookings; DRAFT, SUBMITTED and FAILED are excluded even when a status query is supplied.'
|
||||
description: 'Requires platform:firstleg:ocean-booking:list. The operational list exposes only CONFIRMED, CHANGE_PENDING, RELEASED and CANCELLED bookings; DRAFT is internal to the create transaction and legacy SUBMITTED/FAILED values are invalid.'
|
||||
parameters:
|
||||
- {name: customerId, in: query, schema: {type: integer, format: int64, minimum: 1}}
|
||||
- {name: status, in: query, schema: {type: string, enum: [CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED]}}
|
||||
@@ -20,20 +20,15 @@ paths:
|
||||
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
|
||||
responses: {'200': {description: Operational real booking page}}
|
||||
post:
|
||||
summary: Create an ocean booking draft with immutable channel and warehouse snapshots
|
||||
summary: Register an offline-confirmed ocean booking with complete transport snapshots
|
||||
description: 'Requires platform:firstleg:ocean-booking:create. The booking has already been completed offline, so registration persists it directly as CONFIRMED. RELEASED remains a separate later action.'
|
||||
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateRequest'}}}}
|
||||
responses: {'200': {description: Draft detail}}
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/confirm:
|
||||
post:
|
||||
summary: Confirm a real booked container record without creating internal cabinets
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
|
||||
responses: {'200': {description: CONFIRMED real booking detail; internal cabinets are linked explicitly from the container API}}
|
||||
responses: {'200': {description: CONFIRMED real booking detail}}
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/release:
|
||||
post:
|
||||
summary: Confirm released transport data and synchronize associated cabinet transport snapshots
|
||||
description: Does not create cabinets, change channels, replace cabinetNo, or recreate loading plans.
|
||||
summary: Release an already registered booking and synchronize associated cabinet transport snapshots
|
||||
description: 'The transport fields were registered at create time. The request must repeat the same bill of lading, vessel, voyage, ETD and ETA values; mismatches are rejected. This action does not create cabinets, change channels, replace cabinetNo, or recreate loading plans.'
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}}
|
||||
responses: {'200': {description: RELEASED detail}}
|
||||
@@ -50,7 +45,7 @@ components:
|
||||
data: {$ref: '#/components/schemas/DraftData'}
|
||||
DraftData:
|
||||
type: object
|
||||
required: [channelCode, warehouseId, warehouseName, serviceTerm, freightTerm, parties, containerRequirements]
|
||||
required: [channelCode, warehouseId, warehouseName, billOfLadingNo, vesselName, voyageNo, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, parties, containerRequirements]
|
||||
properties:
|
||||
customerId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional customer snapshot; must be supplied together with customerName.}
|
||||
customerName: {type: string, maxLength: 128, nullable: true, description: Optional customer snapshot; must be supplied together with customerId.}
|
||||
@@ -59,11 +54,11 @@ components:
|
||||
warehouseName: {type: string, maxLength: 128}
|
||||
serviceTerm: {type: string, maxLength: 32, description: Required free-text Service Term snapshot, for example CY-CY.}
|
||||
freightTerm: {type: string, maxLength: 32, description: Required free-text Freight Term snapshot, for example NINGBO.}
|
||||
billOfLadingNo: {type: string, nullable: true, deprecated: true, description: Must be omitted until release}
|
||||
vesselName: {type: string, nullable: true, deprecated: true, description: Must be omitted until release}
|
||||
voyageNo: {type: string, nullable: true, deprecated: true, description: Must be omitted until release}
|
||||
estimatedSailingAt: {type: string, format: date-time, nullable: true, deprecated: true, description: Must be omitted until release}
|
||||
estimatedArrivalAt: {type: string, format: date-time, nullable: true, deprecated: true, description: Must be omitted until release}
|
||||
billOfLadingNo: {type: string, maxLength: 128, description: Required offline-confirmed bill of lading snapshot.}
|
||||
vesselName: {type: string, maxLength: 128, description: Required offline-confirmed vessel snapshot.}
|
||||
voyageNo: {type: string, maxLength: 64, description: Required offline-confirmed voyage snapshot.}
|
||||
estimatedSailingAt: {type: string, format: date-time, description: Required offline-confirmed ETD snapshot.}
|
||||
estimatedArrivalAt: {type: string, format: date-time, description: Required offline-confirmed ETA snapshot; cannot be earlier than ETD.}
|
||||
parties: {type: array, minItems: 3, items: {type: object}}
|
||||
containerRequirements: {type: array, minItems: 1, maxItems: 20, items: {$ref: '#/components/schemas/ContainerRequirement'}}
|
||||
ContainerRequirement:
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ class CustomerTransitStatisticsContractTest {
|
||||
.findFirst().orElseThrow().getAnnotation(org.apache.ibatis.annotations.Select.class).value();
|
||||
var statement = String.join("\n", sql);
|
||||
assertThat(statement).contains("actual_sailing_at IS NOT NULL", "actual_warehouse_arrival_at IS NULL",
|
||||
"status NOT IN ('CANCELLED', 'FAILED')", "DISTINCT x.customer_id, x.container_booking_id",
|
||||
"status <> 'CANCELLED'", "DISTINCT x.customer_id, x.container_booking_id",
|
||||
"b.customer_id", "i.account_id", "i.deleted = 0", "NOT EXISTS",
|
||||
"x.customer_id = #{customerId}", "LIKE '20%'", "THEN 1.0",
|
||||
"LIKE '40%'", "THEN 2.0", "LIKE '45%'", "THEN 2.25", "ELSE NULL");
|
||||
|
||||
+9
-8
@@ -23,15 +23,10 @@ class PlatformOceanBookingControllerContractTest {
|
||||
Map.entry("channels", "platform:firstleg:ocean-booking:list"),
|
||||
Map.entry("detail", "platform:firstleg:ocean-booking:list"),
|
||||
Map.entry("create", "platform:firstleg:ocean-booking:create"),
|
||||
Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"),
|
||||
Map.entry("submit", "platform:firstleg:ocean-booking:submit"),
|
||||
Map.entry("confirm", "platform:firstleg:ocean-booking:confirm"),
|
||||
Map.entry("fail", "platform:firstleg:ocean-booking:confirm"),
|
||||
Map.entry("release", "platform:firstleg:ocean-booking:release"),
|
||||
Map.entry("cancel", "platform:firstleg:ocean-booking:cancel"),
|
||||
Map.entry("requestChange", "platform:firstleg:ocean-booking:change"),
|
||||
Map.entry("confirmChange", "platform:firstleg:ocean-booking:change"),
|
||||
Map.entry("failChange", "platform:firstleg:ocean-booking:change"));
|
||||
Map.entry("confirmChange", "platform:firstleg:ocean-booking:change"));
|
||||
permissions.forEach((name, permission) -> {
|
||||
Method method = method(name);
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
@@ -56,13 +51,19 @@ class PlatformOceanBookingControllerContractTest {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml"));
|
||||
assertThat(yaml).contains("/api/platform/v1/ocean-bookings/channels:",
|
||||
"originWarehouseId", "platform:firstleg:ocean-booking:list", "ChannelOption")
|
||||
.contains("required: [channelCode, warehouseId, warehouseName, serviceTerm, freightTerm, parties, containerRequirements]")
|
||||
.contains("required: [channelCode, warehouseId, warehouseName, billOfLadingNo, vesselName, voyageNo, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, parties, containerRequirements]")
|
||||
.contains("registration persists it directly as CONFIRMED")
|
||||
.contains("RELEASED remains a separate later action")
|
||||
.contains("request must repeat the same bill of lading, vessel, voyage, ETD and ETA values; mismatches are rejected")
|
||||
.contains("CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED")
|
||||
.contains("DRAFT, SUBMITTED and FAILED are excluded")
|
||||
.contains("DRAFT is internal to the create transaction and legacy SUBMITTED/FAILED values are invalid")
|
||||
.contains("customerId: {type: integer, format: int64, minimum: 1, nullable: true")
|
||||
.contains("customerName: {type: string, maxLength: 128, nullable: true")
|
||||
.contains("serviceTerm: {type: string, maxLength: 32, description: Required free-text")
|
||||
.contains("freightTerm: {type: string, maxLength: 32, description: Required free-text")
|
||||
.contains("billOfLadingNo: {type: string, maxLength: 128, description: Required offline-confirmed")
|
||||
.contains("estimatedArrivalAt: {type: string, format: date-time, description: Required offline-confirmed ETA")
|
||||
.doesNotContain("Must be omitted until release")
|
||||
.doesNotContain("required: [customerId, customerName, channelCode, channelName");
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -30,9 +30,10 @@ class OceanBookingMapperContractTest {
|
||||
.flatMap(annotation -> Arrays.stream(annotation.value()))
|
||||
.reduce("", (left, right) -> left + '\n' + right);
|
||||
assertThat(sql)
|
||||
.contains("status='DRAFT' AND version=#{expectedVersion}")
|
||||
.contains("status=#{fromStatus} AND version=#{expectedVersion}")
|
||||
.contains("status='CONFIRMED' AND version=#{expectedVersion}")
|
||||
.contains("status='CHANGE_PENDING' AND version=#{expectedVersion}");
|
||||
.contains("status='CHANGE_PENDING' AND version=#{expectedVersion}")
|
||||
.doesNotContain("SUBMITTED")
|
||||
.doesNotContain("FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-5
@@ -32,9 +32,9 @@ class OceanBookingSqlContractTest {
|
||||
@Test
|
||||
void enforcesStateIdempotencyAndAuditContracts() throws Exception {
|
||||
assertThat(sql())
|
||||
.contains("OLD.status='DRAFT' AND NEW.status IN ('SUBMITTED','CANCELLED')")
|
||||
.contains("OLD.status='DRAFT' AND NEW.status='CONFIRMED'")
|
||||
.contains("OLD.status='CONFIRMED' AND NEW.status IN ('RELEASED','CHANGE_PENDING','CANCELLED')")
|
||||
.contains("OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','FAILED','CANCELLED')")
|
||||
.contains("OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','CANCELLED')")
|
||||
.contains("OLD.status IN ('RELEASED','CANCELLED')")
|
||||
.contains("UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key)")
|
||||
.contains("request_hash CHAR(64) NOT NULL")
|
||||
@@ -47,9 +47,6 @@ class OceanBookingSqlContractTest {
|
||||
assertThat(sql())
|
||||
.contains("platform:firstleg:ocean-booking:list")
|
||||
.contains("platform:firstleg:ocean-booking:create")
|
||||
.contains("platform:firstleg:ocean-booking:edit")
|
||||
.contains("platform:firstleg:ocean-booking:submit")
|
||||
.contains("platform:firstleg:ocean-booking:confirm")
|
||||
.contains("platform:firstleg:ocean-booking:release")
|
||||
.contains("platform:firstleg:ocean-booking:cancel")
|
||||
.contains("platform:firstleg:ocean-booking:change")
|
||||
|
||||
+100
-100
@@ -10,7 +10,6 @@ import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DraftData;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.PartyRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.UpdateDraftRequest;
|
||||
import com.baiyue.firstleg.service.LoadingPickService;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
|
||||
@@ -27,6 +26,7 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.math.BigDecimal;
|
||||
@@ -55,27 +55,8 @@ class OceanBookingServiceTest {
|
||||
when(repository.histories(anyLong())).thenReturn(List.of());
|
||||
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void advancesDraftToSubmittedWithVersionAndHistory() {
|
||||
when(repository.find(1L)).thenReturn(booking("DRAFT", 2), booking("SUBMITTED", 3));
|
||||
var shipper = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity(); shipper.setPartyType("SHIPPER");
|
||||
var consignee = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity(); consignee.setPartyType("CONSIGNEE");
|
||||
var notify = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity(); notify.setPartyType("NOTIFY_PARTY");
|
||||
when(repository.parties(1L)).thenReturn(List.of(shipper, consignee, notify));
|
||||
when(repository.requirements(1L)).thenReturn(List.of(new com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity()));
|
||||
when(repository.transition(1L, "DRAFT", "SUBMITTED", null, null, 2, 9L, "admin")).thenReturn(1);
|
||||
|
||||
Detail result = service.submit(1L, 9L, "admin", "submit-1", new ActionRequest(2, "提交"));
|
||||
|
||||
assertThat(result.oceanBooking().getStatus()).isEqualTo("SUBMITTED");
|
||||
ArgumentCaptor<OceanBookingStatusHistoryEntity> history =
|
||||
ArgumentCaptor.forClass(OceanBookingStatusHistoryEntity.class);
|
||||
verify(repository).insert(history.capture());
|
||||
assertThat(history.getValue().getFromStatus()).isEqualTo("DRAFT");
|
||||
assertThat(history.getValue().getToStatus()).isEqualTo("SUBMITTED");
|
||||
assertThat(history.getValue().getAction()).isEqualTo("SUBMIT");
|
||||
when(repository.transition(anyLong(), eq("DRAFT"), eq("CONFIRMED"), isNull(), eq(0),
|
||||
anyLong(), anyString())).thenReturn(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -86,12 +67,16 @@ class OceanBookingServiceTest {
|
||||
new ActionRequest(4, "错误"))).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("不可逆");
|
||||
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), any(), any(), anyInt(), anyLong(), anyString());
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), any(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void opensAllocatedContainerPdaTasksAfterRelease() {
|
||||
OceanBookingEntity confirmed = booking("CONFIRMED", 3);
|
||||
confirmed.setBillOfLadingNo("BL-001"); confirmed.setVesselName("VESSEL");
|
||||
confirmed.setVoyageNo("V001");
|
||||
confirmed.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
|
||||
confirmed.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0));
|
||||
OceanBookingEntity released = booking("RELEASED", 4);
|
||||
var allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setContainerBookingId(12L);
|
||||
@@ -107,6 +92,25 @@ class OceanBookingServiceTest {
|
||||
verify(loadingPickService).beginIfReady(12L, 9L, "admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsReleaseWhenTransportFieldsDifferFromRegisteredSnapshot() {
|
||||
OceanBookingEntity confirmed = booking("CONFIRMED", 3);
|
||||
confirmed.setBillOfLadingNo("BL-001"); confirmed.setVesselName("VESSEL");
|
||||
confirmed.setVoyageNo("V001");
|
||||
confirmed.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
|
||||
confirmed.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0));
|
||||
when(repository.find(1L)).thenReturn(confirmed);
|
||||
|
||||
var request = new ReleaseRequest(3, "BL-CHANGED", "VESSEL", "V001",
|
||||
LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 20, 0, 0), "放舱");
|
||||
|
||||
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-mismatch", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("必须与已登记");
|
||||
|
||||
verify(repository, never()).release(anyLong(), any(), anyLong(), anyString());
|
||||
verifyNoInteractions(loadingPickService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsCurrentVoyagePendingUntilChangeConfirmation() {
|
||||
OceanBookingEntity current = booking("CONFIRMED", 5);
|
||||
@@ -126,27 +130,11 @@ class OceanBookingServiceTest {
|
||||
assertThat(result.oceanBooking().getPendingVesselName()).isEqualTo("NEW");
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaysCompletedRequestBeforeVersionValidation() throws Exception {
|
||||
OceanBookingEntity submitted = booking("SUBMITTED", 3);
|
||||
Detail replay = new Detail(submitted, List.of(), List.of(), List.of(), List.of());
|
||||
OceanBookingOperationEntity operation = new OceanBookingOperationEntity();
|
||||
operation.setRequestHash(hashForSubmit()); operation.setStatus("SUCCESS");
|
||||
operation.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(replay));
|
||||
when(repository.claimOperation(eq(1L), eq(9L), eq("SUBMIT"), eq("submit-replay"), anyString())).thenReturn(false);
|
||||
when(repository.operation(9L, "SUBMIT", "submit-replay")).thenReturn(operation);
|
||||
|
||||
Detail result = service.submit(1L, 9L, "admin", "submit-replay", new ActionRequest(2, "提交"));
|
||||
|
||||
assertThat(result.oceanBooking().getStatus()).isEqualTo("SUBMITTED");
|
||||
verify(repository, never()).find(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDraftAndSubmittedStatusesFromOperationalList() {
|
||||
for (String status : List.of("DRAFT", "SUBMITTED", "FAILED")) {
|
||||
assertThatThrownBy(() -> service.page(new Query(null, status, null, 1, 20)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("仅支持");
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("状态");
|
||||
}
|
||||
verify(repository, never()).page(any());
|
||||
}
|
||||
@@ -176,19 +164,33 @@ class OceanBookingServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void freezesAuthoritativeChannelNameWhenCreatingDraft() {
|
||||
void registersOfflineBookingDirectlyAsConfirmedWithCompleteTransportSnapshot() {
|
||||
ChannelRow channel = channel("SEA-US", "价格簿美西海运");
|
||||
when(priceBooks.activeVersionId()).thenReturn(51L);
|
||||
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel));
|
||||
AtomicReference<OceanBookingEntity> inserted = new AtomicReference<>();
|
||||
doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; })
|
||||
.when(repository).insert(any(OceanBookingEntity.class));
|
||||
when(repository.find(anyLong())).thenAnswer(invocation -> inserted.get());
|
||||
AtomicReference<OceanBookingEntity> inserted = stubSuccessfulCreation();
|
||||
|
||||
Detail result = service.create(9L, "admin", "create-channel", new CreateRequest(0, draft()));
|
||||
|
||||
assertThat(inserted.get().getStatus()).isEqualTo("DRAFT");
|
||||
assertThat(result.oceanBooking().getStatus()).isEqualTo("CONFIRMED");
|
||||
assertThat(result.oceanBooking().getChannelCode()).isEqualTo("SEA-US");
|
||||
assertThat(result.oceanBooking().getChannelName()).isEqualTo("价格簿美西海运");
|
||||
assertThat(result.oceanBooking().getBillOfLadingNo()).isEqualTo("BL-001");
|
||||
assertThat(result.oceanBooking().getVesselName()).isEqualTo("VESSEL");
|
||||
assertThat(result.oceanBooking().getVoyageNo()).isEqualTo("V001");
|
||||
assertThat(result.oceanBooking().getEstimatedSailingAt())
|
||||
.isEqualTo(LocalDateTime.of(2026, 9, 1, 0, 0));
|
||||
assertThat(result.oceanBooking().getEstimatedArrivalAt())
|
||||
.isEqualTo(LocalDateTime.of(2026, 9, 20, 0, 0));
|
||||
ArgumentCaptor<OceanBookingStatusHistoryEntity> history =
|
||||
ArgumentCaptor.forClass(OceanBookingStatusHistoryEntity.class);
|
||||
verify(repository).insert(history.capture());
|
||||
assertThat(history.getValue().getFromStatus()).isEqualTo("DRAFT");
|
||||
assertThat(history.getValue().getToStatus()).isEqualTo("CONFIRMED");
|
||||
assertThat(history.getValue().getAction()).isEqualTo("CREATE");
|
||||
verify(repository).transition(inserted.get().getId(), "DRAFT", "CONFIRMED", null, 0, 9L,
|
||||
"admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -196,10 +198,7 @@ class OceanBookingServiceTest {
|
||||
ChannelRow channel = channel("SEA-US", "价格簿美西海运");
|
||||
when(priceBooks.activeVersionId()).thenReturn(51L);
|
||||
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel));
|
||||
AtomicReference<OceanBookingEntity> inserted = new AtomicReference<>();
|
||||
doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; })
|
||||
.when(repository).insert(any(OceanBookingEntity.class));
|
||||
when(repository.find(anyLong())).thenAnswer(invocation -> inserted.get());
|
||||
stubSuccessfulCreation();
|
||||
|
||||
Detail result = service.create(9L, "admin", "create-without-customer",
|
||||
new CreateRequest(0, draft(null, null, "CY-CY", "NINGBO")));
|
||||
@@ -234,16 +233,48 @@ class OceanBookingServiceTest {
|
||||
verifyNoInteractions(priceBooks);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOfflineBookingWhenEtaIsEarlierThanEtd() {
|
||||
DraftData source = draft();
|
||||
DraftData invalid = new DraftData(source.customerId(), source.customerName(), source.channelCode(),
|
||||
source.warehouseId(), source.warehouseName(), source.billOfLadingNo(), source.shippingCompanyId(),
|
||||
source.shippingCompanyName(), source.bookingAgentId(), source.bookingAgentName(),
|
||||
source.vesselName(), source.voyageNo(), source.departurePortCode(), source.departurePortName(),
|
||||
source.destinationPortCode(), source.destinationPortName(), source.placeOfDelivery(),
|
||||
source.serviceTerm(), source.freightTerm(), source.estimatedArrivalAt(), source.estimatedSailingAt(),
|
||||
source.remark(), source.parties(), source.containerRequirements());
|
||||
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", "invalid-range", new CreateRequest(0, invalid)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("预计到港时间不能早于预计开船时间");
|
||||
|
||||
verifyNoInteractions(priceBooks);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOfflineBookingWithoutCompleteTransportSnapshot() {
|
||||
DraftData source = draft();
|
||||
DraftData missingBill = new DraftData(source.customerId(), source.customerName(), source.channelCode(),
|
||||
source.warehouseId(), source.warehouseName(), null, source.shippingCompanyId(),
|
||||
source.shippingCompanyName(), source.bookingAgentId(), source.bookingAgentName(),
|
||||
source.vesselName(), source.voyageNo(), source.departurePortCode(), source.departurePortName(),
|
||||
source.destinationPortCode(), source.destinationPortName(), source.placeOfDelivery(),
|
||||
source.serviceTerm(), source.freightTerm(), source.estimatedSailingAt(), source.estimatedArrivalAt(),
|
||||
source.remark(), source.parties(), source.containerRequirements());
|
||||
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", "missing-transport",
|
||||
new CreateRequest(0, missingBill))).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("提单号、船名、航次、ETD和ETA不能为空");
|
||||
|
||||
verifyNoInteractions(priceBooks);
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaysCreateAfterChannelIsNoLongerActive() throws Exception {
|
||||
ChannelRow channel = channel("SEA-US", "价格簿美西海运");
|
||||
CreateRequest request = new CreateRequest(0, draft());
|
||||
when(priceBooks.activeVersionId()).thenReturn(51L, (Long) null);
|
||||
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel));
|
||||
AtomicReference<OceanBookingEntity> inserted = new AtomicReference<>();
|
||||
doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; })
|
||||
.when(repository).insert(any(OceanBookingEntity.class));
|
||||
when(repository.find(anyLong())).thenAnswer(invocation -> inserted.get());
|
||||
stubSuccessfulCreation();
|
||||
|
||||
Detail created = service.create(9L, "admin", "create-replay-channel", request);
|
||||
|
||||
@@ -260,40 +291,6 @@ class OceanBookingServiceTest {
|
||||
verify(priceBooks, times(1)).activeVersionId();
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaysDraftUpdateAfterChannelIsNoLongerActive() throws Exception {
|
||||
UpdateDraftRequest request = new UpdateDraftRequest(5, draft());
|
||||
Detail expected = new Detail(booking("DRAFT", 6), List.of(), List.of(), List.of(), List.of());
|
||||
OceanBookingOperationEntity operation = new OceanBookingOperationEntity();
|
||||
operation.setRequestHash(hashForUpdate(request)); operation.setStatus("SUCCESS");
|
||||
operation.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(expected));
|
||||
when(repository.claimOperation(eq(1L), eq(9L), eq("UPDATE_DRAFT"), eq("update-replay-channel"), anyString()))
|
||||
.thenReturn(false);
|
||||
when(repository.operation(9L, "UPDATE_DRAFT", "update-replay-channel")).thenReturn(operation);
|
||||
|
||||
Detail replay = service.updateDraft(1L, 9L, "admin", "update-replay-channel", request);
|
||||
|
||||
assertThat(replay.oceanBooking().getVersion()).isEqualTo(6);
|
||||
verifyNoInteractions(priceBooks);
|
||||
verify(repository, never()).find(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsConfirmationWhenFrozenChannelIsNoLongerActive() {
|
||||
OceanBookingEntity submitted = booking("SUBMITTED", 3);
|
||||
submitted.setWarehouseId(701L); submitted.setWarehouseName("深圳仓"); submitted.setChannelName("美西海运");
|
||||
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
|
||||
requirement.setCapacityCbm(java.math.BigDecimal.ONE); requirement.setMaxWeightKg(java.math.BigDecimal.ONE);
|
||||
when(repository.find(1L)).thenReturn(submitted);
|
||||
when(repository.requirements(1L)).thenReturn(List.of(requirement));
|
||||
when(priceBooks.activeVersionId()).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.confirm(1L, 9L, "admin", "confirm-channel", new ActionRequest(3, "确认")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("ACTIVE");
|
||||
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), any(), any(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
private static ChannelRow channel(String code, String name) {
|
||||
ChannelRow value = new ChannelRow(); value.setPriceBookVersionId(51L); value.setChannelCode(code);
|
||||
value.setChannelName(name); value.setPriceModel("FBA_WAREHOUSE_RATE"); return value;
|
||||
@@ -310,15 +307,24 @@ class OceanBookingServiceTest {
|
||||
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null));
|
||||
List<ContainerRequirementRequest> requirements = List.of(
|
||||
new ContainerRequirementRequest("40HQ", "DRY", BigDecimal.ONE, BigDecimal.TEN, 1));
|
||||
return new DraftData(customerId, customerName, "SEA-US", 701L, "深圳仓", null,
|
||||
81L, "船公司", null, null, null, null, "CNSZX", "深圳", "USLAX", "洛杉矶",
|
||||
null, serviceTerm, freightTerm, null, null, null, parties, requirements);
|
||||
return new DraftData(customerId, customerName, "SEA-US", 701L, "深圳仓", "BL-001",
|
||||
81L, "船公司", null, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶",
|
||||
null, serviceTerm, freightTerm, LocalDateTime.of(2026, 9, 1, 0, 0),
|
||||
LocalDateTime.of(2026, 9, 20, 0, 0), null, parties, requirements);
|
||||
}
|
||||
|
||||
private String hashForSubmit() throws Exception {
|
||||
var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class);
|
||||
method.setAccessible(true);
|
||||
return (String) method.invoke(service, "SUBMIT", 1L, new ActionRequest(2, "提交"));
|
||||
private AtomicReference<OceanBookingEntity> stubSuccessfulCreation() {
|
||||
AtomicReference<OceanBookingEntity> inserted = new AtomicReference<>();
|
||||
doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; })
|
||||
.when(repository).insert(any(OceanBookingEntity.class));
|
||||
when(repository.find(anyLong())).thenAnswer(invocation -> {
|
||||
OceanBookingEntity confirmed = new OceanBookingEntity();
|
||||
BeanUtils.copyProperties(inserted.get(), confirmed);
|
||||
confirmed.setStatus("CONFIRMED");
|
||||
confirmed.setVersion(1);
|
||||
return confirmed;
|
||||
});
|
||||
return inserted;
|
||||
}
|
||||
|
||||
private String hashForCreate(CreateRequest request) throws Exception {
|
||||
@@ -327,12 +333,6 @@ class OceanBookingServiceTest {
|
||||
return (String) method.invoke(service, "CREATE", (Long) null, request);
|
||||
}
|
||||
|
||||
private String hashForUpdate(UpdateDraftRequest request) throws Exception {
|
||||
var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class);
|
||||
method.setAccessible(true);
|
||||
return (String) method.invoke(service, "UPDATE_DRAFT", 1L, request);
|
||||
}
|
||||
|
||||
private static OceanBookingEntity booking(String status, int version) {
|
||||
OceanBookingEntity value = new OceanBookingEntity(); value.setId(1L); value.setBookingNo("OB1");
|
||||
value.setStatus(status); value.setCustomerId(100L); value.setCustomerName("客户");
|
||||
|
||||
-6
@@ -25,9 +25,6 @@ class Phase3BookingTruckPermissionSqlContractTest {
|
||||
private static final Set<String> OCEAN_CONTROLLER_PERMISSIONS = Set.of(
|
||||
"platform:firstleg:ocean-booking:list",
|
||||
"platform:firstleg:ocean-booking:create",
|
||||
"platform:firstleg:ocean-booking:edit",
|
||||
"platform:firstleg:ocean-booking:submit",
|
||||
"platform:firstleg:ocean-booking:confirm",
|
||||
"platform:firstleg:ocean-booking:release",
|
||||
"platform:firstleg:ocean-booking:cancel",
|
||||
"platform:firstleg:ocean-booking:change");
|
||||
@@ -35,9 +32,6 @@ class Phase3BookingTruckPermissionSqlContractTest {
|
||||
private static final Set<String> OCEAN_SQL_PERMISSIONS = Set.of(
|
||||
"platform:firstleg:ocean-booking:list",
|
||||
"platform:firstleg:ocean-booking:create",
|
||||
"platform:firstleg:ocean-booking:edit",
|
||||
"platform:firstleg:ocean-booking:submit",
|
||||
"platform:firstleg:ocean-booking:confirm",
|
||||
"platform:firstleg:ocean-booking:release",
|
||||
"platform:firstleg:ocean-booking:cancel",
|
||||
"platform:firstleg:ocean-booking:change",
|
||||
|
||||
@@ -18,7 +18,7 @@ test('operational ocean booking list excludes unconfirmed data', () => {
|
||||
assert.match(oceanService, /OPERATIONAL_LIST_STATUSES\s*=\s*Set\.of\([\s\S]*CONFIRMED[\s\S]*CHANGE_PENDING[\s\S]*RELEASED[\s\S]*CANCELLED/)
|
||||
assert.match(oceanMapper, /status IN \('CONFIRMED','CHANGE_PENDING','RELEASED','CANCELLED'\)/)
|
||||
assert.match(oceanOpenApi, /enum: \[CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED\]/)
|
||||
assert.match(oceanOpenApi, /DRAFT, SUBMITTED and FAILED are excluded/)
|
||||
assert.match(oceanOpenApi, /DRAFT is internal to the create transaction and legacy SUBMITTED\/FAILED values are invalid/)
|
||||
assert.doesNotMatch(oceanOpenApi.match(/name: status[^\n]*/)?.[0] || '', /DRAFT|SUBMITTED/)
|
||||
})
|
||||
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.warehouse.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.warehouse.dto.SupplierDtos.InternalSnapshot;
|
||||
import com.baiyue.warehouse.service.SupplierService;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/internal/warehouse/v1/suppliers")
|
||||
public class InternalSupplierController {
|
||||
@Resource private SupplierService service;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
private byte[] expected;
|
||||
@PostConstruct void initializeToken() { expected = internalToken.getBytes(StandardCharsets.UTF_8); }
|
||||
@GetMapping("/{supplierId}")
|
||||
public ApiResponse<InternalSnapshot> get(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@PathVariable long supplierId, @RequestParam(defaultValue="false") boolean requireActive,
|
||||
@RequestParam(required=false) String category, @RequestParam(required=false) String scope) {
|
||||
if (token == null || !MessageDigest.isEqual(expected, token.getBytes(StandardCharsets.UTF_8))) throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
|
||||
if (category != null && !category.matches("BOOKING|CUSTOMS|TRUCKING|CERTIFICATE")) throw new BizException(ResultCode.VALIDATION_FAILED, "供应商类别无效");
|
||||
if (scope != null && !scope.matches("DOMESTIC|INTERNATIONAL")) throw new BizException(ResultCode.VALIDATION_FAILED, "订舱范围无效");
|
||||
if (scope != null && !"BOOKING".equals(category)) throw new BizException(ResultCode.VALIDATION_FAILED, "scope仅适用于BOOKING");
|
||||
return ApiResponse.success(service.internalSnapshot(supplierId, requireActive, category, scope));
|
||||
}
|
||||
}
|
||||
@@ -42,4 +42,5 @@ public final class SupplierDtos {
|
||||
public record StatusRequest(@NotNull @Pattern(regexp = "ACTIVE|INACTIVE") String status) {}
|
||||
|
||||
public record Stats(long booking, long customs, long trucking, long certificate) {}
|
||||
public record InternalSnapshot(long supplierId, String name, String category, String scope, String status) {}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,11 @@ public class SupplierService {
|
||||
private static final long PUBLIC_TENANT_ID = 0L;
|
||||
@Resource private SupplierRepository repository;
|
||||
|
||||
public PageResult<SupplierEntity> page(PageQuery query) { return repository.page(PUBLIC_TENANT_ID, query); }
|
||||
public PageResult<SupplierEntity> page(PageQuery query) {
|
||||
if (query.category() == null && query.scope() != null) throw new BizException(ResultCode.VALIDATION_FAILED, "scope仅适用于订舱供应商");
|
||||
if (query.category() != null && !"BOOKING".equals(query.category()) && query.scope() != null) throw new BizException(ResultCode.VALIDATION_FAILED, "scope仅适用于订舱供应商");
|
||||
return repository.page(PUBLIC_TENANT_ID, query);
|
||||
}
|
||||
public SupplierEntity detail(long id) { return owned(id); }
|
||||
public Stats stats() { return new Stats(repository.count(0, "BOOKING"), repository.count(0, "CUSTOMS"), repository.count(0, "TRUCKING"), repository.count(0, "CERTIFICATE")); }
|
||||
|
||||
@@ -40,7 +44,15 @@ public class SupplierService {
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "SUPPLIER_STATUS_CHANGE", resourceType = "SUPPLIER", resourceId = "#id", description = "启停供应商")
|
||||
public void changeStatus(long id, StatusRequest request) { SupplierEntity e = owned(id); e.setStatus(request.status()); repository.save(e); }
|
||||
public void changeStatus(long id, StatusRequest request) { SupplierEntity e = owned(id); if (!request.status().equals(e.getStatus())) { e.setStatus(request.status()); repository.save(e); } }
|
||||
|
||||
public InternalSnapshot internalSnapshot(long id, boolean requireActive, String category, String scope) {
|
||||
SupplierEntity e = owned(id);
|
||||
if (requireActive && !"ACTIVE".equals(e.getStatus())) throw new BizException(ResultCode.CONFLICT, "供应商未启用");
|
||||
if (category != null && !category.equals(e.getCategory())) throw new BizException(ResultCode.VALIDATION_FAILED, "供应商类别不匹配");
|
||||
if (scope != null && (!"BOOKING".equals(e.getCategory()) || !scope.equals(e.getScope()))) throw new BizException(ResultCode.VALIDATION_FAILED, "订舱范围不匹配");
|
||||
return new InternalSnapshot(e.getId(), e.getName(), e.getCategory(), e.getScope(), e.getStatus());
|
||||
}
|
||||
|
||||
private SupplierEntity owned(long id) { SupplierEntity e = repository.find(0L, id); if (e == null) throw new BizException(ResultCode.NOT_FOUND, "供应商不存在"); return e; }
|
||||
private void ensureName(String name, Long id) { if (repository.existsName(0L, name, id)) throw new BizException(ResultCode.CONFLICT, "供应商名称已存在"); }
|
||||
|
||||
@@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS wh_supplier (
|
||||
UNIQUE KEY uk_wh_supplier_name (tenant_id,name),
|
||||
KEY idx_wh_supplier_filter (tenant_id,category,status,region),
|
||||
CONSTRAINT chk_wh_supplier_category CHECK (category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')),
|
||||
CONSTRAINT chk_wh_supplier_tenant CHECK (tenant_id = 0),
|
||||
CONSTRAINT chk_wh_supplier_scope CHECK ((category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')) OR (category<>'BOOKING' AND scope IS NULL)),
|
||||
CONSTRAINT chk_wh_supplier_status CHECK (status IN ('ACTIVE','INACTIVE')),
|
||||
CONSTRAINT chk_wh_supplier_settlement CHECK (settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE'))
|
||||
@@ -71,6 +72,7 @@ BEGIN
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL supplier_add_check_if_missing('chk_wh_supplier_category', "category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')");
|
||||
CALL supplier_add_check_if_missing('chk_wh_supplier_tenant', "tenant_id = 0");
|
||||
CALL supplier_add_check_if_missing('chk_wh_supplier_scope', "(category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')) OR (category<>'BOOKING' AND scope IS NULL)");
|
||||
CALL supplier_add_check_if_missing('chk_wh_supplier_status', "status IN ('ACTIVE','INACTIVE')");
|
||||
CALL supplier_add_check_if_missing('chk_wh_supplier_settlement', "settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE')");
|
||||
@@ -82,7 +84,7 @@ CREATE PROCEDURE assert_supplier_schema()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier') <> 1
|
||||
OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name IN ('PRIMARY','uk_wh_supplier_name','idx_wh_supplier_filter')) <> 3
|
||||
OR (SELECT COUNT(*) FROM information_schema.table_constraints WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND constraint_type='CHECK' AND constraint_name IN ('chk_wh_supplier_category','chk_wh_supplier_scope','chk_wh_supplier_status','chk_wh_supplier_settlement')) <> 4 THEN
|
||||
OR (SELECT COUNT(*) FROM information_schema.table_constraints WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND constraint_type='CHECK' AND constraint_name IN ('chk_wh_supplier_category','chk_wh_supplier_tenant','chk_wh_supplier_scope','chk_wh_supplier_status','chk_wh_supplier_settlement')) <> 5 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商表、索引或约束初始化不完整';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
Reference in New Issue
Block a user