fix(firstleg): enable manual nodes for consolidated containers

This commit is contained in:
2026-09-10 11:55:09 +08:00
parent 2821c5e9c7
commit c1a36c6f40
11 changed files with 108 additions and 16 deletions
@@ -18584,6 +18584,17 @@ DROP PROCEDURE IF EXISTS add_finance_p0_column;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_finance' AND table_name='fin_fee_source' AND index_name='idx_fin_fee_source_wallet_hold'), 'SELECT 1', 'ALTER TABLE baiyue_finance.fin_fee_source ADD KEY idx_fin_fee_source_wallet_hold(wallet_hold_id)'));
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
-- 跨客户合柜的订舱属于平台技术作用域,船节点任务使用 customer_id=0。
USE baiyue_firstleg;
SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query'
AND constraint_name='chk_carrier_schedule_query_customer' AND constraint_type='CHECK'
), 'ALTER TABLE fl_carrier_schedule_query DROP CHECK chk_carrier_schedule_query_customer', 'SELECT 1'));
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
ALTER TABLE fl_carrier_schedule_query
ADD CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id >= 0);
CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
SELECT 'PASS' AS migration_status,
@@ -97,7 +97,7 @@ public class OutboundDispatchService {
OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId);
if (carrierSchedules != null) {
if (ocean == null || ocean.id() == null || ocean.customerId() == null) {
if (ocean == null || ocean.id() == null) {
throw conflict("当前货柜未关联有效订舱,无法创建船司查询任务");
}
carrierSchedules.ensureFromOutboundConfirm(ocean.customerId(), ocean.id(), containerBookingId,
@@ -129,7 +129,7 @@ public class OutboundDispatchService {
if (!Objects.equals(visible.version(), request.expectedVersion())) throw concurrent();
OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId);
if (carrierSchedules != null) {
if (ocean == null || ocean.id() == null || ocean.customerId() == null) {
if (ocean == null || ocean.id() == null) {
throw conflict("无法核验货柜后续船节点,禁止撤销国内出仓");
}
if (carrierSchedules.hasOutboundDepartureFact(ocean.customerId(), ocean.id(), containerBookingId)) {
@@ -14,6 +14,9 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -41,6 +44,7 @@ import java.util.List;
@RequestMapping("/api/platform/v1/carrier-schedule-queries")
public class PlatformCarrierScheduleQueryController {
@Resource private CarrierScheduleQueryService service;
@Resource private OutboundDispatchService outboundDispatchService;
@Operation(summary = "查询六家船公司合规适配策略")
@GetMapping("/carriers")
@@ -97,6 +101,23 @@ public class PlatformCarrierScheduleQueryController {
return ApiResponse.success(service.create(user.userId(), user.username(), key, request));
}
@PostMapping("/containers/{containerBookingId}/ensure")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
@Operation(summary = "为已确认离仓货柜补建船节点任务后进入人工补录")
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_ENSURE", resourceType = "CONTAINER_BOOKING",
resourceId = "#containerBookingId", description = "补建货柜船节点任务")
public ApiResponse<Detail> ensureForManualEntry(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId,
@RequestHeader("Idempotency-Key") String key) {
FirstlegScopeGuard.platform(user);
var outbound = outboundDispatchService.detail(containerBookingId).aggregate();
if (!"WAITING_FOR_LOADING".equals(outbound.dispatchStatus()) || outbound.oceanBookingId() == null) {
throw new BizException(ResultCode.CONFLICT, "货柜尚未确认国内出仓,不能人工补录船节点");
}
return ApiResponse.success(service.ensureForManualEntry(outbound.oceanBookingId(), containerBookingId,
user.userId(), user.username(), key));
}
@PostMapping("/{queryId}/manual-results")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_RESULT", resourceType = "CARRIER_SCHEDULE_QUERY",
@@ -13,7 +13,7 @@ import java.util.List;
public final class CarrierScheduleQueryDtos {
private CarrierScheduleQueryDtos() {}
public record Query(@Positive Long customerId, @Positive Long oceanBookingId,
public record Query(@Min(0) Long customerId, @Positive Long oceanBookingId,
@Positive Long containerBookingId, @Size(max = 16) String carrierCode,
@Size(max = 24) String status, Boolean expired,
@Size(max = 128) String keyword, @Min(1) Long page,
@@ -22,7 +22,7 @@ public final class CarrierScheduleQueryDtos {
}
public record CreateRequest(@NotNull @Min(0) @Max(0) Integer expectedVersion,
@NotNull @Positive Long customerId,
@NotNull @Min(0) Long customerId,
@NotNull @Positive Long oceanBookingId,
@NotNull @Positive Long containerBookingId,
@NotBlank @Size(max = 16) String carrierCode,
@@ -101,15 +101,17 @@ public class CarrierScheduleQueryService {
/** Idempotently starts the canonical tracking task after domestic outbound confirmation. */
@Transactional
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(long customerId, long oceanBookingId,
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId,
long containerBookingId,
long operatorId, String operatorName) {
CarrierScheduleQueryEntity existing = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId);
OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId);
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在");
validateLinks(customerId, oceanBookingId, containerBookingId);
long scopeCustomerId = booking.getCustomerId() == null ? 0L : booking.getCustomerId();
if (customerId != null && customerId != scopeCustomerId) throw badRequest("customerId与订舱客户不一致");
CarrierScheduleQueryEntity existing = repository.findByBusinessScope(scopeCustomerId, oceanBookingId,
containerBookingId);
validateLinks(scopeCustomerId, oceanBookingId, containerBookingId);
String trackingType = "CONTAINER_NO";
String trackingNo = normalizedContainerNo(container);
if (existing != null) {
@@ -120,7 +122,7 @@ public class CarrierScheduleQueryService {
LocalDateTime now = now();
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId());
value.setCustomerId(customerId); value.setOceanBookingId(oceanBookingId);
value.setCustomerId(scopeCustomerId); value.setOceanBookingId(oceanBookingId);
value.setContainerBookingId(containerBookingId); value.setCarrierCode(strategy.carrierCode());
value.setTrackingType(trackingType); value.setTrackingNo(trackingNo);
value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode());
@@ -136,7 +138,7 @@ public class CarrierScheduleQueryService {
try {
try { repository.insert(value); }
catch (DuplicateKeyException exception) {
CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId,
CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(scopeCustomerId, oceanBookingId,
containerBookingId);
if (concurrent != null) {
requireFrozenTrackingIdentity(concurrent, trackingNo);
@@ -162,6 +164,13 @@ public class CarrierScheduleQueryService {
return value;
}
@Transactional
public Detail ensureForManualEntry(long oceanBookingId, long containerBookingId,
long operatorId, String operatorName, String key) {
validateKey(key);
return detail(ensureFromOutboundConfirm(null, oceanBookingId, containerBookingId, operatorId, operatorName));
}
private static String outboundManualReason(PreparedAttempt strategy, OceanBookingEntity booking) {
if (!"MANUAL_REQUIRED".equals(strategy.status())) return null;
String reason = strategy.manualReason();
@@ -424,7 +433,8 @@ public class CarrierScheduleQueryService {
private void validateLinks(long customerId, long oceanBookingId, Long containerBookingId) {
OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId);
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "关联订舱不存在");
if (!Objects.equals(booking.getCustomerId(), customerId)) throw badRequest("customerId与订舱客户不一致");
long expectedCustomerId = booking.getCustomerId() == null ? 0L : booking.getCustomerId();
if (expectedCustomerId != customerId) throw badRequest("customerId与订舱客户不一致");
if (containerBookingId != null) {
if (portSchedules.containerBooking(containerBookingId) == null) {
throw new BizException(ResultCode.NOT_FOUND, "关联柜次不存在");
@@ -52,7 +52,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
CONSTRAINT fk_carrier_schedule_query_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_carrier_schedule_query_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT fk_carrier_schedule_query_result FOREIGN KEY (result_port_schedule_id) REFERENCES fl_port_schedule(id),
CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id > 0),
CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id >= 0),
CONSTRAINT chk_carrier_schedule_query_carrier CHECK
(carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')),
CONSTRAINT chk_carrier_schedule_query_tracking CHECK
@@ -34,7 +34,7 @@ paths:
summary: Query carrier schedule tasks
x-permission: platform:firstleg:port-schedule-query:list
parameters:
- {name: customerId, in: query, schema: {type: integer, format: int64, minimum: 1}}
- {name: customerId, in: query, schema: {type: integer, format: int64, minimum: 0}}
- {name: oceanBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}}
- {name: containerBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}}
- {name: carrierCode, in: query, schema: {$ref: '#/components/schemas/TaskCarrierCode'}}
@@ -65,6 +65,17 @@ paths:
'200':
description: Stable task detail
content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}
/api/platform/v1/carrier-schedule-queries/containers/{containerBookingId}/ensure:
post:
summary: Idempotently ensure the carrier task for a departed container before manual entry
x-permission: platform:firstleg:port-schedule-query:manual
parameters:
- {name: containerBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {$ref: '#/components/parameters/IdempotencyKey'}
responses:
'200':
description: Existing or newly created task detail; platform-owned consolidated bookings use customerId 0
content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}
/api/platform/v1/carrier-schedule-queries/{queryId}/history:
parameters: [{$ref: '#/components/parameters/QueryId'}]
get:
@@ -126,7 +137,7 @@ components:
required: [expectedVersion, customerId, oceanBookingId, containerBookingId, carrierCode, trackingType, trackingNo]
properties:
expectedVersion: {type: integer, minimum: 0, maximum: 0}
customerId: {type: integer, format: int64, minimum: 1}
customerId: {type: integer, format: int64, minimum: 0}
oceanBookingId: {type: integer, format: int64, minimum: 1}
containerBookingId: {type: integer, format: int64, minimum: 1}
carrierCode: {$ref: '#/components/schemas/CarrierCode'}
@@ -146,6 +146,24 @@ class OutboundDispatchServiceTest {
verify(carrierSchedules, times(1)).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin");
}
@Test
void confirmCreatesPlatformScopedCarrierTaskForConsolidatedBooking() {
CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class);
OutboundDispatchService service = service(carrierSchedules);
stubContainer();
when(repository.ocean(22L)).thenReturn(new OutboundDispatchReadMapper.OceanBinding(66L, "RELEASED", null));
when(repository.lockBindings(22L)).thenReturn(bindings());
when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "READY_FOR_DISPATCH", 0));
when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "READY_FOR_DISPATCH", 0));
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-platform-booking"), anyString())).thenReturn(true);
when(repository.confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull())).thenReturn(1);
when(repository.completeOperation(31L, 9L, "CONFIRM_DISPATCH", "confirm-platform-booking")).thenReturn(1);
service.confirm(22L, 9L, "admin", "confirm-platform-booking", new ActionRequest(0, null));
verify(carrierSchedules).ensureFromOutboundConfirm(null, 66L, 22L, 9L, "admin");
}
@Test
void confirmDoesNotWriteAnyRowWhenOneInboundGateFails() {
OutboundDispatchService service = serviceWithoutCarrier();
@@ -28,6 +28,8 @@ class CarrierScheduleQueryOpenApiContractTest {
.contains("Estimated facts are never returned or inferred")
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:")
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-nodes:")
.contains("/api/platform/v1/carrier-schedule-queries/containers/{containerBookingId}/ensure:")
.contains("platform-owned consolidated bookings use customerId 0")
.contains("x-permission: platform:firstleg:port-schedule-query:manual")
.contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]")
.contains("LOADED_ON_VESSEL, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED_DESTINATION_PORT")
@@ -23,6 +23,7 @@ class PlatformCarrierScheduleQueryControllerContractTest {
"detail", "platform:firstleg:port-schedule-query:list",
"history", "platform:firstleg:port-schedule-query:list",
"create", "platform:firstleg:port-schedule-query:create",
"ensureForManualEntry", "platform:firstleg:port-schedule-query:manual",
"manualResult", "platform:firstleg:port-schedule-query:manual",
"manualNode", "platform:firstleg:port-schedule-query:manual");
permissions.forEach((name, permission) -> {
@@ -30,7 +31,7 @@ class PlatformCarrierScheduleQueryControllerContractTest {
.filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
if (name.equals("create") || name.equals("manualResult") || name.equals("manualNode")) {
if (name.equals("create") || name.equals("ensureForManualEntry") || name.equals("manualResult") || name.equals("manualNode")) {
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
}
});
@@ -210,6 +210,24 @@ class CarrierScheduleQueryServiceTest {
&& "国内确认出仓自动创建".equals(history.getReason())));
}
@Test
void platformOwnedConsolidatedBookingCreatesContainerTaskInTechnicalScope() {
OceanBookingEntity booking = new OceanBookingEntity();
booking.setCustomerId(null); booking.setCarrierCode("SML"); booking.setShippingCompanyName("SML");
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("ZCSU7881970");
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true);
CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(null, 10L, 20L, 0L, "SYSTEM");
assertThat(result.getCustomerId()).isZero();
verify(repository).findByBusinessScope(0L, 10L, 20L);
verify(repository).insert(argThat((CarrierScheduleQueryEntity task) -> task.getCustomerId() == 0L));
}
@Test
void outboundUnknownCarrierCreatesManualTaskAndPreservesBookingCarrierIdentity() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
@@ -298,7 +316,7 @@ class CarrierScheduleQueryServiceTest {
@Test
void outboundAndCarrierEnsureShareRequiredTransactionalRollbackBoundary() throws Exception {
Transactional carrierBoundary = CarrierScheduleQueryService.class.getMethod("ensureFromOutboundConfirm",
long.class, long.class, long.class, long.class, String.class).getAnnotation(Transactional.class);
Long.class, long.class, long.class, long.class, String.class).getAnnotation(Transactional.class);
Transactional outboundBoundary = OutboundDispatchService.class.getMethod("confirm",
long.class, long.class, String.class, String.class, ActionRequest.class).getAnnotation(Transactional.class);