This commit is contained in:
2026-08-15 10:27:15 +08:00
parent c6d7fb0d84
commit 9e7cc55eab
22 changed files with 336 additions and 34 deletions
@@ -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',
@@ -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',
@@ -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())
@@ -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();
@@ -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/)
@@ -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) {
@@ -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,
@@ -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("""
@@ -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("""
@@ -6,7 +6,7 @@ import com.baiyue.warehouse.mapper.PalletLocationReleaseOperationMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
/** 已占用托盘库位选择性释放的持久化幂等边界。 */
/** 已占用库位选择性释放的持久化幂等边界。 */
@Repository
public class PalletLocationReleaseOperationRepository {
@Resource
@@ -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, "同批预占的幂等键不得重复");
}
}
}
@@ -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());