优化
This commit is contained in:
@@ -8716,6 +8716,109 @@ ALTER TABLE fl_ocean_booking
|
||||
MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照',
|
||||
MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照';
|
||||
|
||||
-- Reconcile databases created before the direct-CONFIRMED contract. This is
|
||||
-- intentionally fail-closed: no legacy row is promoted and no transport term
|
||||
-- is guessed. Referenced legacy aggregates must be archived/deleted by the
|
||||
-- owning module first, then this block can be rerun safely.
|
||||
DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
|
||||
DROP TRIGGER IF EXISTS trg_ocean_party_before_insert;
|
||||
DROP TRIGGER IF EXISTS trg_ocean_party_before_update;
|
||||
DROP TRIGGER IF EXISTS trg_ocean_party_before_delete;
|
||||
DROP TRIGGER IF EXISTS trg_ocean_requirement_before_insert;
|
||||
DROP TRIGGER IF EXISTS trg_ocean_requirement_before_update;
|
||||
DROP TRIGGER IF EXISTS trg_ocean_requirement_before_delete;
|
||||
DROP TRIGGER IF EXISTS trg_ocean_history_before_delete;
|
||||
DELIMITER $$
|
||||
DROP PROCEDURE IF EXISTS reconcile_ocean_booking_legacy$$
|
||||
CREATE PROCEDURE reconcile_ocean_booking_legacy()
|
||||
BEGIN
|
||||
DECLARE v_blank_terms INT DEFAULT 0;
|
||||
DECLARE v_legacy INT DEFAULT 0;
|
||||
DECLARE v_external INT DEFAULT 0;
|
||||
DECLARE v_sql TEXT;
|
||||
|
||||
SELECT COUNT(*) INTO v_blank_terms
|
||||
FROM fl_ocean_booking
|
||||
WHERE NULLIF(TRIM(service_term),'') IS NULL
|
||||
OR NULLIF(TRIM(freight_term),'') IS NULL;
|
||||
IF v_blank_terms > 0 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '订舱结构收敛暂停:存在 service_term/freight_term 为空的真实行,请按原始业务资料补齐后重跑,禁止猜值';
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO v_legacy
|
||||
FROM fl_ocean_booking
|
||||
WHERE status IN ('DRAFT','SUBMITTED','FAILED');
|
||||
IF v_legacy > 0 THEN
|
||||
-- Tables from later phase blocks may not exist on a module-only run. Use
|
||||
-- dynamic SQL so a clean initialization remains repeatable.
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema=DATABASE() AND table_name='fl_port_schedule') THEN
|
||||
SET @ocean_legacy_external := 0;
|
||||
SET v_sql = 'SELECT COUNT(*) INTO @ocean_legacy_external FROM fl_port_schedule p JOIN fl_ocean_booking b ON b.id=p.ocean_booking_id WHERE b.status IN (''DRAFT'',''SUBMITTED'',''FAILED'')';
|
||||
PREPARE stmt FROM v_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET v_external = v_external + COALESCE(@ocean_legacy_external,0);
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query') THEN
|
||||
SET @ocean_legacy_external := 0;
|
||||
SET v_sql = 'SELECT COUNT(*) INTO @ocean_legacy_external FROM fl_carrier_schedule_query q JOIN fl_ocean_booking b ON b.id=q.ocean_booking_id WHERE b.status IN (''DRAFT'',''SUBMITTED'',''FAILED'')';
|
||||
PREPARE stmt FROM v_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET v_external = v_external + COALESCE(@ocean_legacy_external,0);
|
||||
END IF;
|
||||
IF v_external > 0 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '订舱结构收敛暂停:旧订舱仍被港务船期/船司查询引用,请先按子表历史->主表归档顺序清理引用后重跑;不会伪造 CONFIRMED';
|
||||
END IF;
|
||||
|
||||
DELETE FROM fl_ocean_booking_party
|
||||
WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED'));
|
||||
DELETE FROM fl_ocean_booking_container_allocation
|
||||
WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED'));
|
||||
DELETE FROM fl_ocean_booking_container_requirement
|
||||
WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED'));
|
||||
DELETE FROM fl_ocean_booking_operation
|
||||
WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED'));
|
||||
DELETE FROM fl_ocean_booking_status_history
|
||||
WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED'));
|
||||
DELETE FROM fl_ocean_booking
|
||||
WHERE status IN ('DRAFT','SUBMITTED','FAILED');
|
||||
END IF;
|
||||
END$$
|
||||
CALL reconcile_ocean_booking_legacy()$$
|
||||
DROP PROCEDURE reconcile_ocean_booking_legacy$$
|
||||
DELIMITER ;
|
||||
|
||||
-- Existing CHECK constraints are not reconciled by CREATE TABLE IF NOT EXISTS.
|
||||
SET @ocean_drop_status_check = (
|
||||
SELECT IF(COUNT(*) > 0,
|
||||
'ALTER TABLE fl_ocean_booking DROP CHECK chk_ocean_booking_status',
|
||||
'SELECT 1')
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking'
|
||||
AND constraint_name='chk_ocean_booking_status'
|
||||
);
|
||||
PREPARE ocean_drop_status_stmt FROM @ocean_drop_status_check;
|
||||
EXECUTE ocean_drop_status_stmt;
|
||||
DEALLOCATE PREPARE ocean_drop_status_stmt;
|
||||
ALTER TABLE fl_ocean_booking
|
||||
MODIFY COLUMN service_term VARCHAR(32) NOT NULL COMMENT '运输服务条款快照',
|
||||
MODIFY COLUMN freight_term VARCHAR(32) NOT NULL COMMENT '海运费支付条款快照',
|
||||
ADD CONSTRAINT chk_ocean_booking_status CHECK
|
||||
(status IN ('DRAFT','CONFIRMED','RELEASED','CHANGE_PENDING','CANCELLED'));
|
||||
|
||||
SET @ocean_drop_failure_reason = (
|
||||
SELECT IF(COUNT(*) > 0,
|
||||
'ALTER TABLE fl_ocean_booking DROP COLUMN failure_reason',
|
||||
'SELECT 1')
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking'
|
||||
AND column_name='failure_reason'
|
||||
);
|
||||
PREPARE ocean_drop_failure_stmt FROM @ocean_drop_failure_reason;
|
||||
EXECUTE ocean_drop_failure_stmt;
|
||||
DEALLOCATE PREPARE ocean_drop_failure_stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_ocean_booking_party (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '订舱提单关系方ID',
|
||||
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID',
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ public class InboundPalletAllocationRepository {
|
||||
Map<Long, String> reservationNos = mapper.selectList(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
|
||||
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
|
||||
.eq(InboundPalletAllocationEntity::getInboundId, inboundId))
|
||||
.stream().collect(Collectors.toMap(InboundPalletAllocationEntity::getPalletId,
|
||||
.stream().collect(Collectors.toMap(InboundPalletAllocationEntity::getReservationId,
|
||||
InboundPalletAllocationEntity::getReservationNo, (left, right) -> left));
|
||||
mapper.delete(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
|
||||
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
|
||||
@@ -58,7 +58,7 @@ public class InboundPalletAllocationRepository {
|
||||
value.setReservationId(source.reservationId());
|
||||
// 仓储确认响应只返回 reservationId;沿用预占阶段的预占单号快照。
|
||||
value.setReservationNo(source.reservationNo() != null ? source.reservationNo()
|
||||
: reservationNos.get(source.palletId()));
|
||||
: reservationNos.get(source.reservationId()));
|
||||
value.setAllocationStatus(normalize(source.state()));
|
||||
value.setCreatedAt(now);
|
||||
value.setUpdatedAt(now);
|
||||
|
||||
@@ -60,6 +60,109 @@ ALTER TABLE fl_ocean_booking
|
||||
MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照',
|
||||
MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照';
|
||||
|
||||
-- Reconcile databases created before the direct-CONFIRMED contract. This is
|
||||
-- intentionally fail-closed: no legacy row is promoted and no transport term
|
||||
-- is guessed. Referenced legacy aggregates must be archived/deleted by the
|
||||
-- owning module first, then this block can be rerun safely.
|
||||
DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
|
||||
DROP TRIGGER IF EXISTS trg_ocean_party_before_insert;
|
||||
DROP TRIGGER IF EXISTS trg_ocean_party_before_update;
|
||||
DROP TRIGGER IF EXISTS trg_ocean_party_before_delete;
|
||||
DROP TRIGGER IF EXISTS trg_ocean_requirement_before_insert;
|
||||
DROP TRIGGER IF EXISTS trg_ocean_requirement_before_update;
|
||||
DROP TRIGGER IF EXISTS trg_ocean_requirement_before_delete;
|
||||
DROP TRIGGER IF EXISTS trg_ocean_history_before_delete;
|
||||
DELIMITER $$
|
||||
DROP PROCEDURE IF EXISTS reconcile_ocean_booking_legacy$$
|
||||
CREATE PROCEDURE reconcile_ocean_booking_legacy()
|
||||
BEGIN
|
||||
DECLARE v_blank_terms INT DEFAULT 0;
|
||||
DECLARE v_legacy INT DEFAULT 0;
|
||||
DECLARE v_external INT DEFAULT 0;
|
||||
DECLARE v_sql TEXT;
|
||||
|
||||
SELECT COUNT(*) INTO v_blank_terms
|
||||
FROM fl_ocean_booking
|
||||
WHERE NULLIF(TRIM(service_term),'') IS NULL
|
||||
OR NULLIF(TRIM(freight_term),'') IS NULL;
|
||||
IF v_blank_terms > 0 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '订舱结构收敛暂停:存在 service_term/freight_term 为空的真实行,请按原始业务资料补齐后重跑,禁止猜值';
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO v_legacy
|
||||
FROM fl_ocean_booking
|
||||
WHERE status IN ('DRAFT','SUBMITTED','FAILED');
|
||||
IF v_legacy > 0 THEN
|
||||
-- Tables from later phase blocks may not exist on a module-only run. Use
|
||||
-- dynamic SQL so a clean initialization remains repeatable.
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema=DATABASE() AND table_name='fl_port_schedule') THEN
|
||||
SET @ocean_legacy_external := 0;
|
||||
SET v_sql = 'SELECT COUNT(*) INTO @ocean_legacy_external FROM fl_port_schedule p JOIN fl_ocean_booking b ON b.id=p.ocean_booking_id WHERE b.status IN (''DRAFT'',''SUBMITTED'',''FAILED'')';
|
||||
PREPARE stmt FROM v_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET v_external = v_external + COALESCE(@ocean_legacy_external,0);
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query') THEN
|
||||
SET @ocean_legacy_external := 0;
|
||||
SET v_sql = 'SELECT COUNT(*) INTO @ocean_legacy_external FROM fl_carrier_schedule_query q JOIN fl_ocean_booking b ON b.id=q.ocean_booking_id WHERE b.status IN (''DRAFT'',''SUBMITTED'',''FAILED'')';
|
||||
PREPARE stmt FROM v_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET v_external = v_external + COALESCE(@ocean_legacy_external,0);
|
||||
END IF;
|
||||
IF v_external > 0 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '订舱结构收敛暂停:旧订舱仍被港务船期/船司查询引用,请先按子表历史->主表归档顺序清理引用后重跑;不会伪造 CONFIRMED';
|
||||
END IF;
|
||||
|
||||
DELETE FROM fl_ocean_booking_party
|
||||
WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED'));
|
||||
DELETE FROM fl_ocean_booking_container_allocation
|
||||
WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED'));
|
||||
DELETE FROM fl_ocean_booking_container_requirement
|
||||
WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED'));
|
||||
DELETE FROM fl_ocean_booking_operation
|
||||
WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED'));
|
||||
DELETE FROM fl_ocean_booking_status_history
|
||||
WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED'));
|
||||
DELETE FROM fl_ocean_booking
|
||||
WHERE status IN ('DRAFT','SUBMITTED','FAILED');
|
||||
END IF;
|
||||
END$$
|
||||
CALL reconcile_ocean_booking_legacy()$$
|
||||
DROP PROCEDURE reconcile_ocean_booking_legacy$$
|
||||
DELIMITER ;
|
||||
|
||||
-- Existing CHECK constraints are not reconciled by CREATE TABLE IF NOT EXISTS.
|
||||
SET @ocean_drop_status_check = (
|
||||
SELECT IF(COUNT(*) > 0,
|
||||
'ALTER TABLE fl_ocean_booking DROP CHECK chk_ocean_booking_status',
|
||||
'SELECT 1')
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking'
|
||||
AND constraint_name='chk_ocean_booking_status'
|
||||
);
|
||||
PREPARE ocean_drop_status_stmt FROM @ocean_drop_status_check;
|
||||
EXECUTE ocean_drop_status_stmt;
|
||||
DEALLOCATE PREPARE ocean_drop_status_stmt;
|
||||
ALTER TABLE fl_ocean_booking
|
||||
MODIFY COLUMN service_term VARCHAR(32) NOT NULL COMMENT '运输服务条款快照',
|
||||
MODIFY COLUMN freight_term VARCHAR(32) NOT NULL COMMENT '海运费支付条款快照',
|
||||
ADD CONSTRAINT chk_ocean_booking_status CHECK
|
||||
(status IN ('DRAFT','CONFIRMED','RELEASED','CHANGE_PENDING','CANCELLED'));
|
||||
|
||||
SET @ocean_drop_failure_reason = (
|
||||
SELECT IF(COUNT(*) > 0,
|
||||
'ALTER TABLE fl_ocean_booking DROP COLUMN failure_reason',
|
||||
'SELECT 1')
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking'
|
||||
AND column_name='failure_reason'
|
||||
);
|
||||
PREPARE ocean_drop_failure_stmt FROM @ocean_drop_failure_reason;
|
||||
EXECUTE ocean_drop_failure_stmt;
|
||||
DEALLOCATE PREPARE ocean_drop_failure_stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_ocean_booking_party (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '订舱提单关系方ID',
|
||||
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID',
|
||||
|
||||
+13
@@ -42,6 +42,19 @@ class OceanBookingSqlContractTest {
|
||||
.contains("UNIQUE KEY uk_ocean_allocation_container (container_booking_id)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconcilesExistingDatabasesWithoutGuessingLegacyBusinessData() throws Exception {
|
||||
assertThat(sql())
|
||||
.contains("reconcile_ocean_booking_legacy")
|
||||
.contains("service_term/freight_term 为空的真实行")
|
||||
.contains("status IN ('DRAFT','SUBMITTED','FAILED')")
|
||||
.contains("不会伪造 CONFIRMED")
|
||||
.contains("DROP CHECK chk_ocean_booking_status")
|
||||
.contains("ADD CONSTRAINT chk_ocean_booking_status CHECK")
|
||||
.contains("DROP COLUMN failure_reason")
|
||||
.doesNotContain("UPDATE fl_ocean_booking SET status='CONFIRMED'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void declaresDedicatedPlatformPermissions() throws Exception {
|
||||
assertThat(sql())
|
||||
|
||||
+29
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
|
||||
import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper;
|
||||
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
@@ -9,10 +10,14 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.math.BigDecimal;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InboundPalletAllocationRepositoryTest {
|
||||
@@ -42,6 +47,30 @@ class InboundPalletAllocationRepositoryTest {
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoresReservationNumbersByReservationIdWhenPalletIsReused() {
|
||||
InboundPalletAllocationEntity first = allocation(1L, 91L, "RESERVED", 1, 0);
|
||||
first.setPalletId(501L);
|
||||
first.setReservationNo("LR-91");
|
||||
InboundPalletAllocationEntity second = allocation(2L, 92L, "RESERVED", 1, 0);
|
||||
second.setPalletId(501L);
|
||||
second.setReservationNo("LR-92");
|
||||
when(mapper.selectList(any())).thenReturn(List.of(first, second));
|
||||
|
||||
var result = new WarehousePalletLocationClient.BatchResult(2, List.of(
|
||||
new WarehousePalletLocationClient.AllocationResult(91L, null, 1, 501L, "P1", 11L, "A1",
|
||||
new BigDecimal("1.2"), "OCCUPIED"),
|
||||
new WarehousePalletLocationClient.AllocationResult(92L, null, 2, 501L, "P1", 12L, "A2",
|
||||
new BigDecimal("1.2"), "OCCUPIED")));
|
||||
|
||||
repository.replace(10001L, 31L, 88L, 0L, new BigDecimal("1.2"), 2, result);
|
||||
|
||||
ArgumentCaptor<InboundPalletAllocationEntity> inserted = ArgumentCaptor.forClass(InboundPalletAllocationEntity.class);
|
||||
verify(mapper, org.mockito.Mockito.times(2)).insert(inserted.capture());
|
||||
assertThat(inserted.getAllValues()).extracting(InboundPalletAllocationEntity::getReservationNo)
|
||||
.containsExactly("LR-91", "LR-92");
|
||||
}
|
||||
|
||||
private static InboundPalletAllocationEntity allocation(long id, long reservationId, String status,
|
||||
int allocated, int picked) {
|
||||
InboundPalletAllocationEntity entity = new InboundPalletAllocationEntity();
|
||||
|
||||
+12
@@ -54,6 +54,18 @@ class Phase3FinalUnifiedSqlContractTest {
|
||||
.contains("trg_loading_pick_operation_before_delete");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedOceanBookingBlockIncludesExistingSchemaReconciliation() throws Exception {
|
||||
String sql = normalise(Files.readString(UNIFIED));
|
||||
assertThat(sql)
|
||||
.contains("reconcile_ocean_booking_legacy")
|
||||
.contains("DROP CHECK chk_ocean_booking_status")
|
||||
.contains("ADD CONSTRAINT chk_ocean_booking_status CHECK")
|
||||
.contains("DROP COLUMN failure_reason")
|
||||
.contains("不会伪造 CONFIRMED")
|
||||
.doesNotContain("UPDATE fl_ocean_booking SET status='CONFIRMED'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void permissionRangesDoNotOverlapAndBothPlatformRolesAreGranted() throws Exception {
|
||||
String sql = Files.readString(UNIFIED, StandardCharsets.UTF_8);
|
||||
|
||||
@@ -152,6 +152,14 @@ export async function run(context) {
|
||||
const bookingId = id(bookingCreated?.oceanBooking?.id)
|
||||
if (!bookingId) return
|
||||
const bookingConfirmed = bookingCreated
|
||||
const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
|
||||
const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, bookingConfirmed?.oceanBooking?.[field]]))
|
||||
const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '')
|
||||
context.assert(MODULE, 'CONFIRMED 订舱回显完整且冻结五项运输快照', bookingCreateResponse,
|
||||
frozenTransportValid
|
||||
&& transportFields.every((field) => String(frozenTransport[field]) === String(bookingDraft[field])),
|
||||
`bookingId=${bookingId}, ${transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', ')}`, OPTIONS)
|
||||
if (!frozenTransportValid) return
|
||||
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ')
|
||||
context.assert(MODULE, '一次登记直接返回 CONFIRMED 且不自动生成三期内部柜', bookingCreateResponse,
|
||||
bookingConfirmed?.oceanBooking?.status === 'CONFIRMED' && Boolean(requirement?.id)
|
||||
@@ -279,19 +287,16 @@ export async function run(context) {
|
||||
`HTTP=${confirmedBookingTaskResponse.http}, code=${codeOf(confirmedBookingTaskResponse)}, task=${id(confirmedBookingTaskResponse?.body?.id || confirmedBookingTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS)
|
||||
if (!confirmedBookingTaskAbsent) return
|
||||
|
||||
const sailingAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
|
||||
const arrivalAt = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
|
||||
expectedVersion: Number(bookingConfirmed?.oceanBooking?.version),
|
||||
billOfLadingNo: `P3BL-${suffix}`.slice(0, 128), vesselName: `P3VESSEL-${suffix}`.slice(0, 128),
|
||||
voyageNo: `P3V-${suffix}`.slice(0, 64), estimatedSailingAt: sailingAt,
|
||||
estimatedArrivalAt: arrivalAt, reason: context.marker,
|
||||
...frozenTransport, reason: context.marker,
|
||||
}, { 'Idempotency-Key': `${context.marker}-p3-booking-release`.slice(0, 128) })
|
||||
const bookingReleased = context.apiSuccess(MODULE, '三期订舱携带已冻结运输快照并放舱', releaseResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '三期订舱 RELEASED 后保持模拟柜关联身份', releaseResponse,
|
||||
bookingReleased?.oceanBooking?.status === 'RELEASED'
|
||||
&& bookingReleased?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerBookingId),
|
||||
`status=${bookingReleased?.oceanBooking?.status || 'missing'}, containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}`, OPTIONS)
|
||||
&& bookingReleased?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerBookingId)
|
||||
&& transportFields.every((field) => String(bookingReleased?.oceanBooking?.[field]) === String(frozenTransport[field])),
|
||||
`status=${bookingReleased?.oceanBooking?.status || 'missing'}, containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}, transportFrozen=${transportFields.every((field) => String(bookingReleased?.oceanBooking?.[field]) === String(frozenTransport[field]))}`, OPTIONS)
|
||||
if (bookingReleased?.oceanBooking?.status !== 'RELEASED') return
|
||||
|
||||
const pushResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/push-preload`,
|
||||
|
||||
@@ -185,6 +185,9 @@ test('container loading arranges a simulation before linking a confirmed real bo
|
||||
assert.ok(value.includes("'PATCH', `${CONTAINERS}/${containerId}/transport"))
|
||||
assert.match(value, /在放舱前绑定本轮真实箱号/)
|
||||
assert.match(value, /订舱携带已冻结运输快照放舱并满足 PDA 门禁/)
|
||||
assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
|
||||
assert.match(value, /\.\.\.frozenTransport, reason: context\.marker/)
|
||||
assert.doesNotMatch(value, /new Date\(Date\.now\(\)/)
|
||||
assert.match(value, /无真实柜号拒绝推送预装计划/)
|
||||
assert.match(value, /柜号与 RELEASED 订舱就绪后推送预装计划/)
|
||||
assert.match(value, /const confirmedTaskAbsent =[\s\S]*if \(!confirmedTaskAbsent\) return/)
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ public class InternalPalletLocationController {
|
||||
}
|
||||
|
||||
@PostMapping("/rebind-temporary-location")
|
||||
@Operation(summary = "A0临时库位换绑", description = "将一条具体A0托盘占用换绑到同仓空闲普通库位")
|
||||
@Operation(summary = "A0临时库位换绑", description = "将一条具体A0库位占用记录换绑到同仓空闲普通库位")
|
||||
public ApiResponse<PalletAllocationDtos.RebindTemporaryLocationResult> rebindTemporaryLocation(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody PalletAllocationDtos.RebindTemporaryLocationRequest request) {
|
||||
|
||||
+1
-1
@@ -137,7 +137,7 @@ public class PlatformLocationController {
|
||||
|
||||
@PostMapping("/temporary-occupancies/{occupancyId}/rebind")
|
||||
@PreAuthorize("hasAuthority('platform:location:edit')")
|
||||
@Operation(summary = "A0临时库位换绑", description = "将指定A0托盘占用换绑到同仓空闲普通库位")
|
||||
@Operation(summary = "A0临时库位换绑", description = "将指定A0库位占用记录换绑到同仓空闲普通库位")
|
||||
public ApiResponse<PalletAllocationDtos.RebindTemporaryLocationResult> rebindTemporaryLocation(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long occupancyId,
|
||||
@Valid @RequestBody RebindRequest request) {
|
||||
|
||||
@@ -67,7 +67,7 @@ public final class LocationDtos {
|
||||
public record Detail(LocationEntity location, String warehouseCode, String warehouseName) {
|
||||
}
|
||||
|
||||
/** 单个托盘的幂等预占目标。 */
|
||||
/** 单条库位预占目标;托盘字段仅引用可复用主数据快照。 */
|
||||
public record ReservationTarget(
|
||||
@NotNull @Min(1) Long palletId,
|
||||
@NotBlank @Size(max = 50) String palletCode,
|
||||
|
||||
@@ -131,7 +131,7 @@ public final class PalletAllocationDtos {
|
||||
) {
|
||||
}
|
||||
|
||||
/** 将共享临时库位 A0 中的一条具体托盘占用换绑到同仓空闲普通库位。 */
|
||||
/** 将共享临时库位 A0 中的一条具体库位占用换绑到同仓空闲普通库位。 */
|
||||
public record RebindTemporaryLocationRequest(
|
||||
@Min(0) long tenantId,
|
||||
@Min(1) long occupancyId,
|
||||
@@ -155,7 +155,7 @@ public final class PalletAllocationDtos {
|
||||
) {
|
||||
}
|
||||
|
||||
/** 仅转移占用归属和未拣出箱数,不释放库位或托盘。 */
|
||||
/** 仅转移库位占用归属和未拣出箱数;托盘仅作为主数据快照关联。 */
|
||||
public record TransferRequest(
|
||||
@Min(0) long tenantId,
|
||||
@NotBlank @Size(max = 40) String sourceService,
|
||||
@@ -168,7 +168,7 @@ public final class PalletAllocationDtos {
|
||||
) {
|
||||
}
|
||||
|
||||
/** 子入库单保存托盘库位快照所需的完整仓储分配结果。 */
|
||||
/** 子入库单保存托盘主数据和库位快照所需的完整仓储分配结果。 */
|
||||
public record TransferResult(
|
||||
long reservationId,
|
||||
String reservationNo,
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
/** 已占用托盘库位选择性释放的幂等操作 Mapper。 */
|
||||
/** 已占用库位选择性释放的幂等操作 Mapper。 */
|
||||
@Mapper
|
||||
public interface PalletLocationReleaseOperationMapper extends BaseMapper<PalletLocationReleaseOperationEntity> {
|
||||
@Insert("""
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
/** 托盘库位转移幂等操作 Mapper。 */
|
||||
/** 库位归属转移幂等操作 Mapper。 */
|
||||
@Mapper
|
||||
public interface PalletLocationTransferOperationMapper extends BaseMapper<PalletLocationTransferOperationEntity> {
|
||||
@Insert("""
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import com.baiyue.warehouse.mapper.PalletLocationReleaseOperationMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/** 已占用托盘库位选择性释放的持久化幂等边界。 */
|
||||
/** 已占用库位选择性释放的持久化幂等边界。 */
|
||||
@Repository
|
||||
public class PalletLocationReleaseOperationRepository {
|
||||
@Resource
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import com.baiyue.warehouse.mapper.PalletLocationTransferOperationMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/** 托盘库位归属转移的幂等操作持久化边界。 */
|
||||
/** 库位归属转移的幂等操作持久化边界。 */
|
||||
@Repository
|
||||
public class PalletLocationTransferOperationRepository {
|
||||
@Resource
|
||||
|
||||
@@ -108,7 +108,7 @@ public class LocationResourceService {
|
||||
result.add(toReservationResult(reservation));
|
||||
}
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new BizException(ResultCode.CONFLICT, "库位预占幂等键、托盘或库位已被占用");
|
||||
throw new BizException(ResultCode.CONFLICT, "库位预占幂等键重复或库位已存在活动预占");
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
@@ -151,7 +151,7 @@ public class LocationResourceService {
|
||||
result.add(toOccupancyResult(occupancy));
|
||||
}
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new BizException(ResultCode.CONFLICT, "库位或托盘已存在活动占用");
|
||||
throw new BizException(ResultCode.CONFLICT, "库位已存在活动占用或预占记录已确认");
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
@@ -336,7 +336,7 @@ public class LocationResourceService {
|
||||
|
||||
/**
|
||||
* 将未拣货的已占用库位从主入库单转给二级子入库单。
|
||||
* 转移只改变业务归属和剩余箱数,不释放库位或托盘。
|
||||
* 转移只改变业务归属和剩余箱数,不释放库位;托盘仅作为关联快照。
|
||||
*/
|
||||
@Transactional
|
||||
public List<LocationDtos.TransferResult> transferToChild(@Valid LocationDtos.TransferCommand command) {
|
||||
@@ -394,7 +394,7 @@ public class LocationResourceService {
|
||||
return List.copyOf(results);
|
||||
}
|
||||
|
||||
/** PDA 结算按预约记录转移未拣货资源,物理托盘和库位继续保持占用。 */
|
||||
/** PDA 结算按预约记录转移未拣货资源,库位继续保持占用,托盘仅作为主数据快照关联。 */
|
||||
@Transactional
|
||||
public List<LocationDtos.TransferResult> transferToChildByReservation(
|
||||
@Valid LocationDtos.ReservationTransferCommand command) {
|
||||
@@ -471,7 +471,7 @@ public class LocationResourceService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** 将 A0 的一条托盘占用换绑到同仓空闲普通库位。 */
|
||||
/** 将 A0 的一条库位占用记录换绑到同仓空闲普通库位,保留托盘主数据关联。 */
|
||||
@Transactional
|
||||
public LocationDtos.RebindTemporaryLocationResult rebindTemporaryLocation(
|
||||
@Valid LocationDtos.RebindTemporaryLocationCommand command) {
|
||||
@@ -648,7 +648,7 @@ public class LocationResourceService {
|
||||
|| reservation.getWarehouseId() != command.warehouseId()
|
||||
|| reservation.getPalletId() != target.palletId()
|
||||
|| !reservation.getPalletCode().equals(target.palletCode())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "库位预占幂等键已用于其他业务或托盘");
|
||||
throw new BizException(ResultCode.CONFLICT, "库位预占幂等键已用于其他业务或托盘主数据");
|
||||
}
|
||||
if ("RELEASED".equals(reservation.getReservationStatus()) || "EXPIRED".equals(reservation.getReservationStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "库位预占幂等请求已释放或过期");
|
||||
@@ -684,11 +684,12 @@ public class LocationResourceService {
|
||||
}
|
||||
|
||||
private static void validateUniqueTargets(List<ReservationTarget> targets) {
|
||||
Set<Long> pallets = new HashSet<>();
|
||||
Set<String> keys = new HashSet<>();
|
||||
for (ReservationTarget target : targets) {
|
||||
if (!pallets.add(target.palletId()) || !keys.add(target.idempotencyKey())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "同批预占的托盘和幂等键不得重复");
|
||||
// Pallets are reusable master-data references. Only the idempotency key
|
||||
// identifies a unique reservation target within one batch.
|
||||
if (!keys.add(target.idempotencyKey())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "同批预占的幂等键不得重复");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -185,7 +185,7 @@ public class PalletLocationAllocationService {
|
||||
*/
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PALLET_LOCATION_SETTLEMENT", resourceType = "INBOUND",
|
||||
resourceId = "#request.sourceBusinessId", description = "PDA拣货后原子结算托盘库位资源")
|
||||
resourceId = "#request.sourceBusinessId", description = "PDA拣货后原子结算库位资源")
|
||||
public PalletAllocationDtos.SettlementResult settle(PalletAllocationDtos.SettlementRequest request) {
|
||||
validateSettlementScope(request);
|
||||
String key = request.idempotencyKey().trim();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- PDA 已拣满库位/托盘精确释放的持久化幂等契约;可在二期库位表建表后重复执行。
|
||||
-- PDA 已拣满库位精确释放的持久化幂等契约;可在二期库位表建表后重复执行。
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wh_pallet_location_release_operation (
|
||||
@@ -16,4 +16,4 @@ CREATE TABLE IF NOT EXISTS wh_pallet_location_release_operation (
|
||||
UNIQUE KEY uk_pallet_location_release_idempotency
|
||||
(tenant_id, source_service, business_id, idempotency_key),
|
||||
KEY idx_pallet_location_release_business (tenant_id, source_service, business_id, operation_status)
|
||||
) ENGINE=InnoDB COMMENT='PDA已拣满托盘库位精确释放的持久化幂等操作记录';
|
||||
) ENGINE=InnoDB COMMENT='PDA已拣满库位精确释放的持久化幂等操作记录';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- PDA 拣货资源原子结算幂等契约;同一数据库事务内完成精确释放和剩余资源转移。
|
||||
-- PDA 拣货库位资源原子结算幂等契约;同一数据库事务内完成精确释放和剩余资源转移。
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wh_pallet_location_settlement_operation (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- 托盘库位转移的持久化幂等契约;可在二期托盘库位表建表后重复执行。
|
||||
-- 库位归属转移的持久化幂等契约;可在二期托盘库位表建表后重复执行。
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wh_pallet_location_transfer_operation (
|
||||
@@ -17,4 +17,4 @@ CREATE TABLE IF NOT EXISTS wh_pallet_location_transfer_operation (
|
||||
UNIQUE KEY uk_pallet_location_transfer_idempotency
|
||||
(tenant_id, source_service, source_business_id, target_business_id, idempotency_key),
|
||||
KEY idx_pallet_location_transfer_source (tenant_id, source_service, source_business_id, operation_status)
|
||||
) ENGINE=InnoDB COMMENT='托盘库位归属转移的持久化幂等操作记录';
|
||||
) ENGINE=InnoDB COMMENT='库位归属转移的持久化幂等操作记录';
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -25,6 +26,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
@@ -117,6 +119,37 @@ class LocationResourceServiceTest {
|
||||
.containsExactly("inbound-1-p1", "inbound-1-p2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowsReusablePalletMasterInMultipleReservationTargets() {
|
||||
when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse());
|
||||
when(repository.lockAvailable(10001L, 88L, 2))
|
||||
.thenReturn(List.of(location(9L, "A1"), location(10L, "A2")));
|
||||
when(repository.changeStatus(10001L, 88L, 9L, "AVAILABLE", "RESERVED", 0, 7L)).thenReturn(1);
|
||||
when(repository.changeStatus(10001L, 88L, 10L, "AVAILABLE", "RESERVED", 0, 7L)).thenReturn(1);
|
||||
|
||||
var command = new ReserveCommand(10001L, 88L, "FIRSTLEG", 301L, "RK001", 7L,
|
||||
LocalDateTime.now().plusMinutes(30), List.of(
|
||||
new ReservationTarget(501L, "P1", "same-pallet-1"),
|
||||
new ReservationTarget(501L, "P1", "same-pallet-2")));
|
||||
|
||||
assertThat(service.reserveAvailable(command)).hasSize(2);
|
||||
verify(repository, times(2)).insertReservation(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reservationConstraintFailureDoesNotDescribePalletAsAResource() {
|
||||
when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse());
|
||||
when(repository.lockAvailable(10001L, 88L, 2))
|
||||
.thenReturn(List.of(location(9L, "A1"), location(10L, "A2")));
|
||||
when(repository.changeStatus(10001L, 88L, 9L, "AVAILABLE", "RESERVED", 0, 7L)).thenReturn(1);
|
||||
doThrow(new DataIntegrityViolationException("duplicate location reservation"))
|
||||
.when(repository).insertReservation(any());
|
||||
|
||||
assertThatThrownBy(() -> service.reserveAvailable(reserveCommand()))
|
||||
.hasMessageContaining("库位预占幂等键重复或库位已存在活动预占")
|
||||
.hasMessageNotContaining("托盘");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleCase04() {
|
||||
when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse());
|
||||
|
||||
Reference in New Issue
Block a user