diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index 7ecca193f..d01ab88c4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -170,10 +170,7 @@ public class ContainerService { } return required(id); } - ContainerBookingEntity current = repository.findForUpdate(id); - if (current == null || !"ACTIVE".equals(current.getRecordStatus())) { - throw new BizException(ResultCode.NOT_FOUND, "货柜不存在"); - } + ContainerBookingEntity current = required(id); requireVersion(current, request.version()); if (status(current) == SmartLoadingStatus.COMPLETED) throw conflict("装柜完成后货柜全部只读,不能修改"); replay = transportHistories == null ? null diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index e0af00088..ea83223ac 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -317,17 +317,27 @@ public class SmartLoadingService { List others = new ArrayList<>(repository.items(planId)); others.removeIf(x -> Objects.equals(x.getId(), itemId)); checkRouteAndCapacity(booking, others, candidate, request.packageCount()); + String before = json(adjustSnapshot(item)); + LoadingPlanItemEntity desired = new LoadingPlanItemEntity(); + desired.setPackageCount(request.packageCount()); + desired.setGrossWeightKg(unit(candidate.unitGrossWeightKg()).multiply(BigDecimal.valueOf(request.packageCount()))); + desired.setNetWeightKg(unit(candidate.unitNetWeightKg()).multiply(BigDecimal.valueOf(request.packageCount()))); + desired.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount()))); + desired.setPalletCount(estimatedPalletCount(candidate, request.packageCount())); + applyCargo(desired, cargo, request.packageCount()); + if (sameAdjustSnapshot(item, desired)) { + return view(plan, booking); + } touch(plan, operatorId, operatorName); - item.setPackageCount(request.packageCount()); - item.setGrossWeightKg(unit(candidate.unitGrossWeightKg()).multiply(BigDecimal.valueOf(request.packageCount()))); - item.setNetWeightKg(unit(candidate.unitNetWeightKg()).multiply(BigDecimal.valueOf(request.packageCount()))); - item.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount()))); - item.setPalletCount(estimatedPalletCount(candidate, request.packageCount())); applyCargo(item, cargo, request.packageCount()); + item.setPackageCount(desired.getPackageCount()); + item.setGrossWeightKg(desired.getGrossWeightKg()); + item.setNetWeightKg(desired.getNetWeightKg()); + item.setVolumeCbm(desired.getVolumeCbm()); + item.setPalletCount(desired.getPalletCount()); applyCargo(item, cargo, request.packageCount()); item.setUpdatedBy(operatorId); item.setUpdatedByName(operatorName); if (repository.update(item) != 1) throw conflict("装柜明细已被并发修改"); history(planId, itemId, "ADJUST_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation, - json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())), - json(values("requestHash", requestHash, "packageCount", request.packageCount())), + before, json(values("requestHash", requestHash, "business", adjustSnapshot(item))), operatorId, operatorName); loadingPickService.sync(planId, item); return view(repository.plan(planId), requireBooking(plan.getContainerBookingId())); @@ -483,6 +493,33 @@ public class SmartLoadingService { } } + private static boolean sameAdjustSnapshot(LoadingPlanItemEntity current, LoadingPlanItemEntity desired) { + return Objects.equals(current.getPackageCount(), desired.getPackageCount()) + && decimalEquals(current.getGrossWeightKg(), desired.getGrossWeightKg()) + && decimalEquals(current.getNetWeightKg(), desired.getNetWeightKg()) + && decimalEquals(current.getVolumeCbm(), desired.getVolumeCbm()) + && Objects.equals(current.getPalletCount(), desired.getPalletCount()) + && Objects.equals(current.getSkuCode(), desired.getSkuCode()) + && Objects.equals(current.getProductNameEn(), desired.getProductNameEn()) + && Objects.equals(current.getUnitsPerCarton(), desired.getUnitsPerCarton()) + && Objects.equals(current.getProductQuantity(), desired.getProductQuantity()) + && Objects.equals(current.getCustomerRemark(), desired.getCustomerRemark()) + && Objects.equals(current.getInternalRemark(), desired.getInternalRemark()); + } + + private static boolean decimalEquals(BigDecimal left, BigDecimal right) { + return left == null ? right == null : right != null && left.compareTo(right) == 0; + } + + private static Map adjustSnapshot(LoadingPlanItemEntity item) { + return values("packageCount", item.getPackageCount(), "grossWeightKg", item.getGrossWeightKg(), + "netWeightKg", item.getNetWeightKg(), "volumeCbm", item.getVolumeCbm(), + "palletCount", item.getPalletCount(), "skuCode", item.getSkuCode(), + "productNameEn", item.getProductNameEn(), "unitsPerCarton", item.getUnitsPerCarton(), + "productQuantity", item.getProductQuantity(), "customerRemark", item.getCustomerRemark(), + "internalRemark", item.getInternalRemark(), "version", item.getVersion()); + } + private OrderCargoClient.CargoBinding loadCargo(Candidate candidate) { try { ApiResponse response = orderCargoClient.detail(internalToken, diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index f84c6d7d7..593bc4cdc 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -330,9 +330,10 @@ paths: /api/platform/v1/containers/{id}/transport: patch: summary: 修改集装箱号和航次 - description: 需要 platform:firstleg:container:edit 权限;LOADING 仅开放此白名单,COMPLETED 全部只读 + description: 需要 platform:firstleg:container:edit 权限;LOADING 仅开放此白名单,COMPLETED 全部只读;相同幂等键精确重放零新增历史,相同业务字段为无效变更且不递增版本、不写历史 parameters: - {$ref: '#/components/parameters/ContainerBookingId'} + - {$ref: '#/components/parameters/IdempotencyKey'} requestBody: required: true content: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 5dd81555f..7c0625f34 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -154,12 +154,63 @@ class ContainerServiceTest { when(repository.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin")) .thenReturn(1); - service.updateTransport(11L, 9L, "admin", + service.updateTransport(11L, 9L, "admin", "transport-1", new TransportUpdateRequest("MSCU1234567", "V002", 2)); } verify(repository, org.mockito.Mockito.times(1)) .updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin"); + ArgumentCaptor history = + ArgumentCaptor.forClass(ContainerTransportHistoryEntity.class); + verify(transportHistories).insert(history.capture()); + assertThat(history.getValue().getBeforeContainerNo()).isEqualTo("MSCU1234567"); + assertThat(history.getValue().getAfterVoyageNo()).isEqualTo("V002"); + assertThat(history.getValue().getOperationId()).hasSize(64); + assertThat(history.getValue().getRequestHash()).hasSize(64); + } + + @Test + @DisplayName("运输字段没有有效变化时不递增版本且不写历史") + void ignoresTransportNoOp() { + ContainerBookingEntity current = booking("LOADING", 2); + current.setVoyageNo("V002"); + when(repository.find(11L)).thenReturn(current); + + ContainerBookingEntity result = service.updateTransport(11L, 9L, "admin", "transport-noop", + new TransportUpdateRequest(" MSCU1234567 ", " V002 ", 2)); + + assertThat(result).isSameAs(current); + verify(repository, never()).updateTransport(anyLong(), any(), any(), anyInt(), anyLong(), any()); + verify(transportHistories, never()).insert(any(ContainerTransportHistoryEntity.class)); + verifyNoInteractions(loadingPickService); + } + + @Test + @DisplayName("运输字段精确幂等重放零新增") + void replaysTransportWithoutSecondMutationOrHistory() { + ContainerBookingEntity current = booking("LOADING", 2); + ContainerBookingEntity updated = booking("LOADING", 3); + updated.setVoyageNo("V002"); + when(repository.find(11L)).thenReturn(current, updated, updated); + when(repository.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin")) + .thenReturn(1); + ArgumentCaptor history = + ArgumentCaptor.forClass(ContainerTransportHistoryEntity.class); + + TransportUpdateRequest request = new TransportUpdateRequest("MSCU1234567", "V002", 2); + service.updateTransport(11L, 9L, "admin", "transport-replay", request); + verify(transportHistories).insert(history.capture()); + when(transportHistories.selectOperation(eq(11L), eq(9L), anyString())) + .thenReturn(history.getValue()); + + ContainerBookingEntity replay = service.updateTransport( + 11L, 9L, "admin", "transport-replay", request); + + assertThat(replay.getVersion()).isEqualTo(3); + verify(repository, org.mockito.Mockito.times(1)) + .updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin"); + verify(transportHistories, org.mockito.Mockito.times(1)) + .insert(any(ContainerTransportHistoryEntity.class)); } @Test @@ -175,7 +226,7 @@ class ContainerServiceTest { .thenReturn(1); when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan); - service.updateTransport(11L, 9L, "admin", + service.updateTransport(11L, 9L, "admin", "transport-2", new TransportUpdateRequest("MSCU1234567", "V002", 2)); verify(loadingPickService).beginIfReady(11L, 9L, "admin"); @@ -195,7 +246,7 @@ class ContainerServiceTest { when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan); when(loadingPickRepository.taskByPlan(31L)).thenReturn(new LoadingPickTaskEntity()); - service.updateTransport(11L, 9L, "admin", + service.updateTransport(11L, 9L, "admin", "transport-3", new TransportUpdateRequest("MSCU7654321", "V002", 2)); verify(loadingPickService).beginIfReady(11L, 9L, "admin"); @@ -211,7 +262,7 @@ class ContainerServiceTest { when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan); when(loadingPickRepository.taskByPlan(31L)).thenReturn(new LoadingPickTaskEntity()); - assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin", + assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin", "transport-4", new TransportUpdateRequest(null, "V002", 2))) .isInstanceOf(BizException.class) .hasMessageContaining("不能清空真实集装箱号"); @@ -225,7 +276,7 @@ class ContainerServiceTest { void rejectsConfirmedUpdate() { when(repository.find(11L)).thenReturn(booking("COMPLETED", 2)); - assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin", + assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin", "transport-5", new TransportUpdateRequest("MSCU1234567", "V002", 2))) .isInstanceOf(BizException.class) .hasMessageContaining("全部只读"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 212b488d3..b7dd1a378 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -42,6 +42,7 @@ import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @DisplayName("预装与装柜清单状态服务") @@ -102,6 +103,25 @@ class LoadingListServiceTest { eq("operator"), eq("补录箱号")); } + @Test + void shouldNotWritePlanOrHistoryWhenTransportFieldsAreUnchanged() { + AggregateRow before = row("LOADING", 3); + before.setContainerNo("CSLU1234567"); + before.setVoyageNo("023E"); + when(repository.aggregate(10L)).thenReturn(before); + when(repository.preloadDetail(before)).thenReturn( + new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); + + service.updateTransport(10L, 100L, 9L, "operator", "noop-key", + new TransportUpdateRequest(" CSLU1234567 ", " 023E ", 3, "重复保存")); + + verify(repository, never()).updateTransport(anyLong(), org.mockito.ArgumentMatchers.anyInt(), + any(), any(), anyLong(), any()); + verify(repository, never()).appendHistory(anyLong(), anyString(), anyString(), any(), any(), + any(), any(), anyLong(), any(), any()); + verifyNoInteractions(loadingPickService); + } + @Test void pushedEditingPreloadCanReceiveContainerNumber() { AggregateRow before = row("EDITING", 3); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 8f39dfa00..4351f70ad 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -503,6 +503,26 @@ class SmartLoadingServiceTest { verify(repository).updateWarehouseRemark(1L, 8L, 2, 0, "待核对外箱", 7L, "u"); } + @Test + void unchangedWarehouseRemarkDoesNotTouchPlanItemOrHistory() { + LoadingPlanEntity plan = plan("LOADING", 2); + LoadingPlanItemEntity line = item(8, 9, 20, 1); + line.setWarehouseRemark("待核对外箱"); + when(repository.plan(1L)).thenReturn(plan); + when(repository.item(1L, 8L)).thenReturn(line); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of(line)); + + Plan result = service.updateWarehouseRemark(1L, 8L, + new WarehouseRemarkRequest(" 待核对外箱 ", 2, 0, "warehouse-noop"), 7L, "u"); + + assertThat(result.version()).isEqualTo(2); + verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString()); + verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(), + any(), anyLong(), anyString()); + verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class)); + } + @Test void warehouseRemarkConflictProducesNoHistory() { LoadingPlanEntity plan = plan("LOADING", 2); diff --git a/scripts/admin-test-env-modules/loading-history-noop-contract.test.mjs b/scripts/admin-test-env-modules/loading-history-noop-contract.test.mjs new file mode 100644 index 000000000..789e4ada4 --- /dev/null +++ b/scripts/admin-test-env-modules/loading-history-noop-contract.test.mjs @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict' +import { readdirSync, readFileSync } from 'node:fs' +import test from 'node:test' + +import { isPureStatusNoop, visibleLoadingHistories } from './loading-history-noop.mjs' + +const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') +const firstlegServiceDirectory = new URL('../../firstleg/src/main/java/com/baiyue/firstleg/service/', import.meta.url) +const serviceSources = readdirSync(firstlegServiceDirectory) + .filter((name) => name.endsWith('.java')) + .map((name) => read(`../../firstleg/src/main/java/com/baiyue/firstleg/service/${name}`)).join('\n') +const smartLoading = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java') +const smartLoadingTest = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java') +const loadingDtos = read('../../firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java') +const loadingRepository = read('../../firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java') +const openapi = read('../../firstleg/src/main/resources/openapi/loading-lists-v1.yaml') +const adminApi = read('../../../apps/admin-web/src/modules/loading-lists/api/platformLoadingLists.ts') +const adminDrawer = read('../../../apps/admin-web/src/modules/loading-lists/components/LoadingListDetailDrawer.vue') +const adminTests = read('../../../apps/admin-web/src/modules/loading-lists/components/LoadingListsView.test.ts') + +test('reference projection removes only pure STATUS_UPDATE no-ops', () => { + assert.equal(isPureStatusNoop({ actionType: 'STATUS_UPDATE', fromStatus: 'EDITING', toStatus: 'EDITING', + beforeSnapshot: '{"status":"EDITING","version":1}', afterSnapshot: '{"version":2,"status":"EDITING"}' }), true) + assert.equal(isPureStatusNoop({ actionType: 'STATUS_UPDATE', fromStatus: 'EDITING', toStatus: 'LOADING' }), false) + assert.equal(isPureStatusNoop({ actionType: 'STATUS_UPDATE', fromStatus: 'EDITING', toStatus: 'EDITING', + beforeSnapshot: '{"containerNo":null}', afterSnapshot: '{"containerNo":"MSCU1"}' }), false) + assert.equal(isPureStatusNoop({ actionType: 'PUSH_PRELOAD', fromStatus: 'EDITING', toStatus: 'EDITING' }), false) +}) + +test('deduplication uses stable event identity and never identical display text', () => { + const rows = visibleLoadingHistories([ + { historyId: '1', operationId: 'push-1', actionType: 'PUSH_PRELOAD', operationReason: '推送预装' }, + { historyId: '2', operationId: 'push-1', actionType: 'PUSH_PRELOAD', operationReason: '推送预装' }, + { historyId: '3', operationId: 'push-2', actionType: 'PUSH_PRELOAD', operationReason: '推送预装' }, + { historyId: '4', actionType: 'ADJUST_ITEM', operationReason: '相同文案' }, + { historyId: '5', actionType: 'ADJUST_ITEM', operationReason: '相同文案' }, + ]) + assert.deepEqual(rows.map((row) => row.historyId), ['1', '3', '4', '5']) +}) + +test('backend never appends a generic STATUS_UPDATE and push replay is read-only', () => { + assert.doesNotMatch(serviceSources, /(?:history|appendHistory)\([^;]{0,500}"STATUS_UPDATE"/s) + assert.match(smartLoading, /if \(key\.equals\(plan\.getPushIdempotencyKey\(\)\)\)[\s\S]{0,300}return view\(plan, booking\)/) + assert.match(smartLoading, /history\(planId, null, "PUSH_PRELOAD"/) + assert.match(smartLoadingTest, /exactPushReplayIsReadOnly[\s\S]{0,1000}never\(\)\.insert/) + assert.match(smartLoadingTest, /getActionType\(\)\)\.isEqualTo\("PUSH_PRELOAD"\)/) +}) + +test('operationId is projected end-to-end for identity-based frontend deduplication', () => { + assert.match(loadingDtos, /record History\([\s\S]{0,180}String operationId/) + assert.match(loadingRepository, /new History\([\s\S]{0,180}getOperationId\(\)/) + assert.match(openapi, /OperationHistory:[\s\S]{0,500}operationId:/) + assert.match(adminApi, /interface LoadingHistory[\s\S]{0,260}operationId:\s*string/) + assert.match(adminApi, /normalizeHistory[\s\S]{0,260}operationId:/) +}) + +test('Admin counts and renders the filtered history projection', () => { + assert.match(adminDrawer, /(?:visible|effective|filtered)Histories/) + assert.doesNotMatch(adminDrawer, /detail\.histories\.length\s*\}\}\s*条/) + assert.doesNotMatch(adminDrawer, /v-for="history in detail\.histories"/) + assert.match(adminDrawer, /STATUS_UPDATE/) + assert.match(adminDrawer, /operationId|eventId/) +}) + +test('Admin behavior contract covers no-op, identity duplicate, count and same-text distinct events', () => { + for (const marker of ['STATUS_UPDATE', 'operationId', 'PUSH_PRELOAD']) assert.match(adminTests, new RegExp(marker)) + assert.match(adminTests, /(?:same text|same wording|相同文案|同文案)/i) + assert.match(adminTests, /(?:count|条)/i) +}) diff --git a/scripts/admin-test-env-modules/loading-history-noop.mjs b/scripts/admin-test-env-modules/loading-history-noop.mjs new file mode 100644 index 000000000..c292b80f9 --- /dev/null +++ b/scripts/admin-test-env-modules/loading-history-noop.mjs @@ -0,0 +1,46 @@ +const VOLATILE_SNAPSHOT_FIELDS = new Set([ + 'requestHash', 'version', 'updatedAt', 'operatorId', 'operatorName', 'occurredAt', +]) + +function stableValue(value) { + if (Array.isArray(value)) return value.map(stableValue) + if (!value || typeof value !== 'object') return value + return Object.fromEntries(Object.entries(value) + .filter(([key]) => !VOLATILE_SNAPSHOT_FIELDS.has(key)) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => [key, stableValue(nested)])) +} + +function snapshot(value) { + if (value == null || value === '') return null + try { return stableValue(typeof value === 'string' ? JSON.parse(value) : value) } catch { return value } +} + +function sameSnapshot(left, right) { + return JSON.stringify(snapshot(left)) === JSON.stringify(snapshot(right)) +} + +export function isPureStatusNoop(history) { + if (history?.actionType !== 'STATUS_UPDATE') return false + if ((history?.fromStatus ?? null) !== (history?.toStatus ?? null)) return false + return sameSnapshot(history?.beforeSnapshot, history?.afterSnapshot) +} + +function stableEventKey(history) { + if (history?.operationId) return `operation:${history.operationId}` + if (history?.eventId) return `event:${history.eventId}` + if (history?.historyId) return `history:${history.historyId}` + return null +} + +export function visibleLoadingHistories(histories) { + const seen = new Set() + return (Array.isArray(histories) ? histories : []).filter((history) => { + if (isPureStatusNoop(history)) return false + const key = stableEventKey(history) + if (!key) return true + if (seen.has(key)) return false + seen.add(key) + return true + }) +} diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 0340e092b..e83ddd65e 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1,4 +1,5 @@ import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs' +import { isPureStatusNoop, visibleLoadingHistories } from './loading-history-noop.mjs' import { createHash } from 'node:crypto' const MODULE = 'phase3-loading' @@ -319,6 +320,12 @@ export async function run(context) { const pushedDetailResponse = await context.request('GET', `/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`) const pushedDetail = context.apiSuccess(MODULE, '无号推送后预装清单正式可见', pushedDetailResponse, '', OPTIONS) + const pushedHistories = visibleLoadingHistories(pushedDetail?.histories) + context.assert(MODULE, '首次 push 只保留一条 PUSH_PRELOAD 且无纯状态 no-op', pushedDetailResponse, + pushedHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length === 1 + && !(pushedDetail?.histories || []).some(isPureStatusNoop), + `raw=${pushedDetail?.histories?.length ?? 'invalid'}, effective=${pushedHistories.length}, push=${pushedHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length}`, + OPTIONS) const pushedLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`) const pushedLinks = context.apiSuccess(MODULE, '无号推送自动建立 bridge 关联', pushedLinksResponse, '', OPTIONS) const link = Array.isArray(pushedLinks) ? pushedLinks.find((item) => id(item.inboundId) === inboundId) : null @@ -353,6 +360,12 @@ export async function run(context) { const replayDetailResponse = await context.request('GET', `/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`) const replayDetail = context.apiSuccess(MODULE, '重放后回查预装历史', replayDetailResponse, '', OPTIONS) + const replayHistories = visibleLoadingHistories(replayDetail?.histories) + context.assert(MODULE, 'push 同幂等请求重放历史零新增', replayDetailResponse, + replayHistories.length === pushedHistories.length + && replayHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length === 1, + `before=${pushedHistories.length}, after=${replayHistories.length}, push=${replayHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length}`, + OPTIONS) const replayLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`) const replayLinks = context.apiSuccess(MODULE, '重放后回查 bridge', replayLinksResponse, '', OPTIONS) const replayTaskResponse = await context.request('GET', @@ -380,6 +393,7 @@ export async function run(context) { const rejectedDetailResponse = await context.request('GET', `/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`) const rejectedDetail = context.apiSuccess(MODULE, '非法 push 后回查预装历史', rejectedDetailResponse, '', OPTIONS) + const rejectedHistories = visibleLoadingHistories(rejectedDetail?.histories) const rejectedLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`) const rejectedLinks = context.apiSuccess(MODULE, '非法 push 后回查 bridge', rejectedLinksResponse, '', OPTIONS) const rejectedTaskResponse = await context.request('GET', @@ -391,7 +405,9 @@ export async function run(context) { const rejectedDiff = diffPushPreloadStability(replayBaseline, rejectedSnapshot) context.assert(MODULE, '两类非法 push 后计划版本历史关联和任务仍零漂移', rejectedTaskResponse, failure(sameKeyDifferentBody) && failure(newKeyDuplicate) - && rejectedDiff.length === 0 && codeOf(rejectedTaskResponse) === 'A0404', + && rejectedDiff.length === 0 && codeOf(rejectedTaskResponse) === 'A0404' + && rejectedHistories.length === pushedHistories.length + && rejectedHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length === 1, `changed=${rejectedDiff.join('|') || 'none'}; sameKey=${responseEvidence(sameKeyDifferentBody)}; newKey=${responseEvidence(newKeyDuplicate)}; task=${responseEvidence(rejectedTaskResponse)}`, OPTIONS) if (rejectedDiff.length !== 0 || codeOf(rejectedTaskResponse) !== 'A0404') return