diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java index 3066f458b..5458d4847 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java @@ -32,7 +32,7 @@ public interface OutboundDispatchReadMapper { @Param("containerBookingId") Long containerBookingId); @Select("SELECT id,task_status status FROM fl_loading_pick_task WHERE loading_plan_id=#{planId} " - + "ORDER BY created_at DESC,id DESC LIMIT 1") + + "ORDER BY started_at DESC,id DESC LIMIT 1") IdStatus pick(@Param("planId") long planId); @Select(""" diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java index 6ce5a0f76..6a2730250 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java @@ -11,14 +11,15 @@ import org.springframework.transaction.annotation.Transactional; public class MeasurementSubmissionService { @Resource private InboundMeasurementService measurements; + @Resource + private MeasurementLocationOccupationService locations; @Transactional public InboundMeasurementResponses.MeasurementSubmitted submit( long tenantId, long inboundId, long operatorId, InboundMeasurementRequests.SubmitRequest request) { var result = measurements.submit(tenantId, inboundId, operatorId, request); - // 当前版本不把托盘/库位占用作为测量提交的强制门禁;入库主状态由客服确认测量时推进, - // 仓储资源可在后续作业阶段继续维护。 + locations.occupy(tenantId, inboundId, operatorId, result.measurementId(), result.version()); return result; } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java new file mode 100644 index 000000000..94940c43e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java @@ -0,0 +1,19 @@ +package com.baiyue.firstleg.outbound.dispatch; + +import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class OutboundDispatchReadMapperContractTest { + @Test + void pickTaskQueryOrdersByTheSchemaStartedAtColumn() throws Exception { + String sql = OutboundDispatchReadMapper.class.getMethod("pick", long.class) + .getAnnotation(Select.class).value()[0]; + + assertThat(sql) + .contains("ORDER BY started_at DESC,id DESC") + .doesNotContain("created_at"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java index 1e2716801..63c5bbea5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java @@ -9,9 +9,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.transaction.annotation.Transactional; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @SpringJUnitConfig(MeasurementSubmissionService.class) @@ -37,7 +35,7 @@ class MeasurementSubmissionServiceTest { assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted); - verifyNoInteractions(locations); + verify(locations).occupy(10001L, 88L, 9L, 30L, 3); } @Test @@ -47,6 +45,6 @@ class MeasurementSubmissionServiceTest { 30L, 3, "MEASURE_PENDING", "PENDING", "STANDARD", null, null, null); when(measurements.submit(10001L, 88L, 9L, request)).thenReturn(submitted); assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted); - verifyNoInteractions(locations); + verify(locations).occupy(10001L, 88L, 9L, 30L, 3); } } diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 43ba435c3..26e63b061 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -11,7 +11,7 @@ import process from 'node:process' const DEFAULT_GATEWAY = 'http://8.163.30.128:9000' const DEFAULT_USERNAME = 'platform_admin' const DEFAULT_PASSWORD = 'ChangeMe123!' -const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-warehouse-rbac', 'phase3-order-splitting'] +const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-warehouse-rbac', 'phase3-order-splitting'] const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG' const CUSTOMER_FIELDS = [ 'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative', diff --git a/scripts/admin-test-env-modules/fee-items.mjs b/scripts/admin-test-env-modules/fee-items.mjs new file mode 100644 index 000000000..adfc88468 --- /dev/null +++ b/scripts/admin-test-env-modules/fee-items.mjs @@ -0,0 +1,249 @@ +const MODULE = 'fee-items' +const SCENARIO = 'platform-public-fee-items' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const BASE = '/api/platform/v1/fee-items' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' + +function id(value) { return value == null ? '' : String(value) } +function records(value) { return Array.isArray(value?.records) ? value.records : [] } +function success(response) { return response.http === 200 && String(response.body?.code) === '200' } +function conflict(response) { + return response.http === 200 && ['A0409', '409'].includes(String(response.body?.code)) +} +function forbidden(response) { + return response.http === 403 || (response.http === 200 && ['A0403', '403'].includes(String(response.body?.code))) +} +function query(parameters = {}) { + const values = new URLSearchParams() + for (const [key, value] of Object.entries(parameters)) { + if (value !== undefined && value !== null && value !== '') values.set(key, String(value)) + } + const suffix = values.toString() + return suffix ? `${BASE}?${suffix}` : BASE +} +function payload(suffix, overrides = {}) { + return { + category: '验收临时费用', + tradeScope: 'DOMESTIC', + feeCode: `ACPT_${suffix}`.slice(0, 50), + feeTypeName: `全量验收费用-${suffix}`, + pickupPayable: false, + pickupReceivable: false, + warehousePayable: true, + warehouseReceivable: true, + allocationFee: false, + description: `仅用于 ${suffix} 自动化验收,完成后删除`, + ...overrides, + } +} +function expectedCodes() { + return Array.from({ length: 110 }, (_, index) => `FEE_TYPE_${String(index + 1).padStart(3, '0')}`) +} +function fixture(context) { + const candidates = [context.createdResources?.orders, context.createdResources?.testInbound, + context.createdResources?.inboundMeasurement, context.context?.testInbound] + return candidates.find((item) => item?.tenantId && item?.inboundId && item?.marker === context.marker) || null +} + +async function listAll(context) { + const response = await context.request('GET', query({ page: 1, size: 200 })) + const page = context.apiSuccess(MODULE, '查询全部平台公共费用项目', response, + '未传 accountId', OPTIONS) + return { response, page, rows: records(page) } +} + +export async function run(context) { + const initialPageResponse = await context.request('GET', query({ page: 1, size: 20 })) + const initialPage = context.apiSuccess(MODULE, '费用项目查看权限与分页查询', initialPageResponse, + 'page=1,size=20', OPTIONS) + context.assert(MODULE, '分页总数110且默认20条对应6页', initialPageResponse, + records(initialPage).length === 20 && Number(initialPage?.total) === 110 + && Number(initialPage?.pages) === 6 && Number(initialPage?.page) === 1, + `records=${records(initialPage).length}, total=${initialPage?.total ?? 'n/a'}, pages=${initialPage?.pages ?? 'n/a'}`, OPTIONS) + context.assert(MODULE, '费用项目查询不传 accountId 仍成功', initialPageResponse, + success(initialPageResponse) && !String(initialPageResponse.path || initialPageResponse.request?.path || '').includes('accountId'), + `path=${initialPageResponse.path || 'missing'}`, OPTIONS) + + const initial = await listAll(context) + const initialRows = initial.rows + const domestic = initialRows.filter((row) => row.tradeScope === 'DOMESTIC') + const international = initialRows.filter((row) => row.tradeScope === 'INTERNATIONAL') + const actualCodes = initialRows.map((row) => row.feeCode).sort() + context.assert(MODULE, '初始化110条全部属于平台且状态ACTIVE', initial.response, + initialRows.length === 110 && initialRows.every((row) => id(row.tenantId) === '0' && row.status === 'ACTIVE'), + `rows=${initialRows.length}, platform=${initialRows.filter((row) => id(row.tenantId) === '0').length}, active=${initialRows.filter((row) => row.status === 'ACTIVE').length}`, OPTIONS) + context.assert(MODULE, '国内84条、国际26条范围正确', initial.response, + domestic.length === 84 && international.length === 26, + `DOMESTIC=${domestic.length}, INTERNATIONAL=${international.length}`, OPTIONS) + context.assert(MODULE, '初始化编码完整覆盖 FEE_TYPE_001..FEE_TYPE_110', initial.response, + actualCodes.join(',') === expectedCodes().join(','), + `codes=${actualCodes.length}, first=${actualCodes[0] || 'missing'}, last=${actualCodes.at(-1) || 'missing'}`, OPTIONS) + + const category = initialRows[0]?.category + const categoryResponse = await context.request('GET', query({ category, page: 1, size: 200 })) + const categoryPage = context.apiSuccess(MODULE, '按费用分类筛选', categoryResponse, + `category=${category || 'missing'}`, OPTIONS) + context.assert(MODULE, '分类筛选结果不混入其他分类', categoryResponse, + Boolean(category) && records(categoryPage).length > 0 && records(categoryPage).every((row) => row.category === category), + `records=${records(categoryPage).length}`, OPTIONS) + + for (const [scope, expected] of [['DOMESTIC', 84], ['INTERNATIONAL', 26]]) { + const response = await context.request('GET', query({ tradeScope: scope, page: 1, size: 200 })) + const page = context.apiSuccess(MODULE, `按 ${scope} 贸易范围筛选`, response, '', OPTIONS) + context.assert(MODULE, `${scope} 筛选数量和范围正确`, response, + Number(page?.total) === expected && records(page).every((row) => row.tradeScope === scope), + `total=${page?.total ?? 'n/a'}, records=${records(page).length}`, OPTIONS) + } + + const keywordSource = initialRows[0]?.feeTypeName + const keyword = String(keywordSource || '').slice(0, Math.max(1, Math.min(6, String(keywordSource || '').length))) + const keywordResponse = await context.request('GET', query({ keyword, page: 1, size: 200 })) + const keywordPage = context.apiSuccess(MODULE, '按名称关键词筛选费用项目', keywordResponse, + `keyword=${keyword || 'missing'}`, OPTIONS) + context.assert(MODULE, '关键词筛选结果名称全部命中', keywordResponse, + Boolean(keyword) && records(keywordPage).length > 0 + && records(keywordPage).every((row) => String(row.feeTypeName).includes(keyword)), + `records=${records(keywordPage).length}`, OPTIONS) + + const activeResponse = await context.request('GET', query({ status: 'ACTIVE', page: 1, size: 200 })) + const activePage = context.apiSuccess(MODULE, '按 ACTIVE 状态筛选费用项目', activeResponse, '', OPTIONS) + context.assert(MODULE, 'ACTIVE 筛选返回110条且无停用记录', activeResponse, + Number(activePage?.total) === 110 && records(activePage).every((row) => row.status === 'ACTIVE'), + `total=${activePage?.total ?? 'n/a'}`, OPTIONS) + + const merchantLoginResponse = await context.request('POST', '/api/auth/login', { + username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, + }, '') + const merchantLogin = success(merchantLoginResponse) ? merchantLoginResponse.body?.data : null + const merchantToken = merchantLogin?.accessToken + const forbiddenResponse = merchantToken + ? await context.request('GET', query({ page: 1, size: 20 }), undefined, merchantToken) + : merchantLoginResponse + context.assert(MODULE, '无费用项目平台权限的客户账号被拒绝', forbiddenResponse, + Boolean(merchantToken) && forbidden(forbiddenResponse), + `HTTP=${forbiddenResponse.http}, code=${forbiddenResponse.body?.code ?? 'missing'}`, OPTIONS) + + const suffix = context.marker.replace(/\D/g, '').slice(-12) + const createPayload = payload(suffix) + const createResponse = await context.request('POST', BASE, createPayload) + const created = context.apiSuccess(MODULE, '创建隔离平台公共费用项目', createResponse, + `feeCode=${createPayload.feeCode}`, OPTIONS) + const feeItemId = id(created?.id) + context.assert(MODULE, '新费用项目固定 tenantId=0、ACTIVE 且字段完整', createResponse, + Boolean(feeItemId) && id(created?.tenantId) === '0' && created?.status === 'ACTIVE' + && created?.feeCode === createPayload.feeCode && created?.feeTypeName === createPayload.feeTypeName, + `feeItemId=${feeItemId || 'missing'}, tenantId=${id(created?.tenantId) || 'missing'}, status=${created?.status || 'missing'}`, OPTIONS) + if (!feeItemId) return + + let detailId = '' + let detailDeleted = false + let itemDeleted = false + try { + const duplicateNameResponse = await context.request('POST', BASE, + payload(`${suffix}N`, { feeTypeName: createPayload.feeTypeName })) + context.assert(MODULE, '同贸易范围费用名称重复返回冲突', duplicateNameResponse, + conflict(duplicateNameResponse), + `HTTP=${duplicateNameResponse.http}, code=${duplicateNameResponse.body?.code ?? 'missing'}`, OPTIONS) + + const duplicateCodeResponse = await context.request('POST', BASE, + payload(`${suffix}C`, { tradeScope: 'INTERNATIONAL', feeCode: createPayload.feeCode, + feeTypeName: `${createPayload.feeTypeName}-编码冲突` })) + context.assert(MODULE, '平台公共 feeCode 重复返回冲突', duplicateCodeResponse, + conflict(duplicateCodeResponse), + `HTTP=${duplicateCodeResponse.http}, code=${duplicateCodeResponse.body?.code ?? 'missing'}`, OPTIONS) + + const updatePayload = payload(suffix, { + category: '验收临时费用-已编辑', feeTypeName: `${createPayload.feeTypeName}-已编辑`, + allocationFee: true, description: `${context.marker}-updated`, + }) + const updateResponse = await context.request('PUT', `${BASE}/${encodeURIComponent(feeItemId)}`, updatePayload) + const updated = context.apiSuccess(MODULE, '编辑隔离费用项目', updateResponse, '', OPTIONS) + context.assert(MODULE, '编辑字段持久化且平台归属不变', updateResponse, + id(updated?.id) === feeItemId && id(updated?.tenantId) === '0' + && updated?.category === updatePayload.category && updated?.feeTypeName === updatePayload.feeTypeName + && updated?.allocationFee === true && updated?.description === updatePayload.description, + `feeItemId=${feeItemId}, category=${updated?.category || 'missing'}`, OPTIONS) + + const disableResponse = await context.request('PATCH', `${BASE}/${encodeURIComponent(feeItemId)}/status`, + { status: 'DISABLED' }) + context.apiSuccess(MODULE, '停用隔离费用项目', disableResponse, '', OPTIONS) + const disabledDetailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(feeItemId)}`) + const disabled = context.apiSuccess(MODULE, '回查停用费用项目', disabledDetailResponse, '', OPTIONS) + context.assert(MODULE, '费用项目停用状态已持久化', disabledDetailResponse, + disabled?.status === 'DISABLED', `status=${disabled?.status || 'missing'}`, OPTIONS) + + const inbound = fixture(context) + context.assert(MODULE, '取得本轮客户隔离入库单用于绑定保护', null, + Boolean(inbound), inbound + ? `tenantId=${inbound.tenantId}, inboundId=${inbound.inboundId}` + : 'orders 模块未提供同 marker 入库单,未使用历史资源', OPTIONS) + if (inbound) { + const feeDetailPath = `/api/platform/v1/inbounds/${encodeURIComponent(inbound.inboundId)}/fee-details?accountId=${encodeURIComponent(inbound.tenantId)}` + const detailPayload = { + feeItemId, direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10), + unitPrice: 1.25, currency: 'CNY', amount: 1.25, remark: context.marker, + } + const disabledBindingResponse = await context.request('POST', feeDetailPath, detailPayload) + context.assert(MODULE, '停用费用项目不可绑定新的入库费用', disabledBindingResponse, + conflict(disabledBindingResponse), + `HTTP=${disabledBindingResponse.http}, code=${disabledBindingResponse.body?.code ?? 'missing'}`, OPTIONS) + + const enableResponse = await context.request('PATCH', `${BASE}/${encodeURIComponent(feeItemId)}/status`, + { status: 'ACTIVE' }) + context.apiSuccess(MODULE, '重新启用隔离费用项目用于引用保护', enableResponse, '', OPTIONS) + const bindingResponse = await context.request('POST', feeDetailPath, detailPayload) + const binding = context.apiSuccess(MODULE, '客户入库单绑定平台公共费用项目', bindingResponse, + `accountId=${inbound.tenantId}`, OPTIONS) + detailId = id(binding?.id) + context.assert(MODULE, '费用明细保留客户归属和公共项目快照', bindingResponse, + Boolean(detailId) && id(binding?.tenantId) === id(inbound.tenantId) + && id(binding?.feeItemId) === feeItemId && binding?.feeTypeNameSnapshot === updatePayload.feeTypeName, + `detailId=${detailId || 'missing'}, detailTenantId=${id(binding?.tenantId) || 'missing'}, feeItemId=${id(binding?.feeItemId) || 'missing'}`, OPTIONS) + + const protectedDeleteResponse = await context.request('DELETE', `${BASE}/${encodeURIComponent(feeItemId)}`) + context.assert(MODULE, '任意客户费用明细引用后公共项目不可删除', protectedDeleteResponse, + conflict(protectedDeleteResponse), + `HTTP=${protectedDeleteResponse.http}, code=${protectedDeleteResponse.body?.code ?? 'missing'}, customer=${inbound.tenantId}`, OPTIONS) + + if (detailId) { + const detailDeleteResponse = await context.request('DELETE', + `${feeDetailPath.replace('?', `/${encodeURIComponent(detailId)}?`)}`) + detailDeleted = success(detailDeleteResponse) + context.assert(MODULE, '清理本轮客户费用明细引用', detailDeleteResponse, detailDeleted, + `detailId=${detailId}, code=${detailDeleteResponse.body?.code ?? 'missing'}`, OPTIONS) + } + } + + if (!detailId || detailDeleted) { + const deleteResponse = await context.request('DELETE', `${BASE}/${encodeURIComponent(feeItemId)}`) + itemDeleted = success(deleteResponse) + context.assert(MODULE, '删除无引用的隔离费用项目', deleteResponse, itemDeleted, + `feeItemId=${feeItemId}, code=${deleteResponse.body?.code ?? 'missing'}`, OPTIONS) + } + } finally { + const inbound = fixture(context) + if (detailId && !detailDeleted && inbound) { + const cleanupDetailResponse = await context.request('DELETE', + `/api/platform/v1/inbounds/${encodeURIComponent(inbound.inboundId)}/fee-details/${encodeURIComponent(detailId)}?accountId=${encodeURIComponent(inbound.tenantId)}`) + detailDeleted = success(cleanupDetailResponse) + context.assert(MODULE, '异常路径清理本轮费用明细', cleanupDetailResponse, detailDeleted, + `detailId=${detailId}`, OPTIONS) + } + if (!itemDeleted && (!detailId || detailDeleted)) { + const cleanupItemResponse = await context.request('DELETE', `${BASE}/${encodeURIComponent(feeItemId)}`) + itemDeleted = success(cleanupItemResponse) + context.assert(MODULE, '异常路径清理本轮费用项目', cleanupItemResponse, itemDeleted, + `feeItemId=${feeItemId}`, OPTIONS) + } + const final = await listAll(context) + context.assert(MODULE, '清理后恢复110条基线且无临时编码或名称', final.response, + final.rows.length === 110 && !final.rows.some((row) => row.feeCode === createPayload.feeCode + || String(row.feeTypeName).includes(suffix)), + `rows=${final.rows.length}, temporary=${final.rows.filter((row) => row.feeCode === createPayload.feeCode).length}`, OPTIONS) + context.createdResources.feeItems = { + marker: context.marker, feeItemId, detailId: detailId || null, + cleaned: itemDeleted && (!detailId || detailDeleted), baselineCount: final.rows.length, + } + } +} diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs index 813e1ed16..c3d1bbaba 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs @@ -5,6 +5,12 @@ const OCEAN = '/api/platform/v1/ocean-bookings' const PORT = '/api/platform/v1/port-schedules' function id(value) { return value == null ? '' : String(value) } +export function matchesManualScheduleScope(resource, expected) { + return Boolean(resource?.id) + && id(resource.customerId) === id(expected.customerId) + && id(resource.oceanBookingId) === id(expected.oceanBookingId) + && id(resource.containerBookingId) === id(expected.containerBookingId) +} function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function data(response) { return codeOf(response) === '200' ? response.body.data : null } function localIso(value) { return value.toISOString().slice(0, 19) } @@ -60,7 +66,8 @@ async function ensureOcean(context, suffix) { async function createManualSchedule(context, suffix, oceanBookingId) { const existing = context.createdResources?.portSchedule - if (existing?.id && id(existing.oceanBookingId) === oceanBookingId) return id(existing.id) + const expectedScope = { customerId: '10001', oceanBookingId, containerBookingId: null } + if (matchesManualScheduleScope(existing, expectedScope)) return id(existing.id) const body = { expectedVersion: 0, data: { diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs new file mode 100644 index 000000000..70155c9d1 --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { matchesManualScheduleScope } from './phase3-carrier-schedule-queries.mjs' + +const expected = { customerId: '10001', oceanBookingId: '20001', containerBookingId: null } + +test('does not reuse a schedule from the same booking when the container scope differs', () => { + assert.equal(matchesManualScheduleScope({ + id: '30001', customerId: '10001', oceanBookingId: '20001', containerBookingId: '40001', + }, expected), false) +}) + +test('does not reuse a schedule whose customer scope is unknown', () => { + assert.equal(matchesManualScheduleScope({ + id: '30001', oceanBookingId: '20001', containerBookingId: null, + }, expected), false) +}) + +test('reuses a schedule only when customer, booking and container scopes all match', () => { + assert.equal(matchesManualScheduleScope({ + id: '30001', customerId: 10001, oceanBookingId: 20001, containerBookingId: null, + }, expected), true) +})