fix(firstleg): enable manual nodes for consolidated containers
This commit is contained in:
@@ -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,
|
||||
|
||||
+2
-2
@@ -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)) {
|
||||
|
||||
+21
@@ -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",
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+17
-7
@@ -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'}
|
||||
|
||||
+18
@@ -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();
|
||||
|
||||
+2
@@ -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")
|
||||
|
||||
+2
-1
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
+19
-1
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user