Test #1

Merged
admin merged 1726 commits from test into main 2026-09-17 11:52:45 +08:00
22 changed files with 236 additions and 327 deletions
Showing only changes of commit 20907e4685 - Show all commits
@@ -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', '最后修改操作人姓名快照');
@@ -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}
@@ -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;
@@ -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);
@@ -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(),
@@ -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:
@@ -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");
@@ -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");
}
@@ -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");
}
}
@@ -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")
@@ -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("客户");
@@ -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/)
})
@@ -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$$