diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/cn/firstleg-service.yml index 36a658d02..3ca256ca4 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/cn/firstleg-service.yml @@ -21,6 +21,7 @@ baiyue: emc-automated: ${EMC_TRACKING_ENABLED:false} hede-automated: ${HEDE_TRACKING_ENABLED:false} scheduler: + enabled: ${CARRIER_SCHEDULE_SCHEDULER_ENABLED:false} zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai} scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M} scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S} diff --git a/config/nacos/local/firstleg-service.yml b/config/nacos/local/firstleg-service.yml index b893c82b5..1da4fd0e1 100644 --- a/config/nacos/local/firstleg-service.yml +++ b/config/nacos/local/firstleg-service.yml @@ -15,6 +15,7 @@ baiyue.carrier-schedule.providers.cosco-official-api-enabled: ${COSCO_OPEN_API_E baiyue.carrier-schedule.providers.emc-automated: ${EMC_TRACKING_ENABLED:false} baiyue.carrier-schedule.providers.hede-automated: ${HEDE_TRACKING_ENABLED:false} baiyue.carrier-schedule.scheduler.zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai} +baiyue.carrier-schedule.scheduler.enabled: ${CARRIER_SCHEDULE_SCHEDULER_ENABLED:false} baiyue.carrier-schedule.scheduler.scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M} baiyue.carrier-schedule.scheduler.scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S} baiyue.carrier-schedule.scheduler.batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 8bfb76788..aad13a528 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -96,6 +96,7 @@ services: COSCO_OPEN_API_ENABLED: ${COSCO_OPEN_API_ENABLED:-false} EMC_TRACKING_ENABLED: ${EMC_TRACKING_ENABLED:-false} HEDE_TRACKING_ENABLED: ${HEDE_TRACKING_ENABLED:-false} + CARRIER_SCHEDULE_SCHEDULER_ENABLED: ${CARRIER_SCHEDULE_SCHEDULER_ENABLED:-false} CARRIER_SCHEDULE_ZONE: ${CARRIER_SCHEDULE_ZONE:-Asia/Shanghai} CARRIER_SCHEDULE_SCAN_FIXED_DELAY: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:-PT1M} CARRIER_SCHEDULE_SCAN_INITIAL_DELAY: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:-PT10S} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java index 3f746a807..22fee924f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java @@ -6,6 +6,7 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObs import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -18,6 +19,7 @@ import java.util.List; import java.util.UUID; @Component +@ConditionalOnProperty(prefix = "baiyue.carrier-schedule.scheduler", name = "enabled", havingValue = "true") public class CarrierScheduleQueryScheduler { private final CarrierScheduleQueryRepository repository; private final CarrierScheduleQueryService service; diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index bd086dd12..fbb976456 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -39,6 +39,7 @@ baiyue: emc-automated: ${EMC_TRACKING_ENABLED:false} hede-automated: ${HEDE_TRACKING_ENABLED:false} scheduler: + enabled: ${CARRIER_SCHEDULE_SCHEDULER_ENABLED:false} zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai} scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M} scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java index 8607f987d..cee52bf2b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java @@ -4,6 +4,7 @@ import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.test.util.ReflectionTestUtils; @@ -27,6 +28,18 @@ import static org.mockito.Mockito.when; import static org.mockito.Mockito.never; class CarrierScheduleQuerySchedulerTest { + @Test + void isDisabledUnlessTheAutomaticSchedulerIsExplicitlyEnabled() { + ConditionalOnProperty condition = CarrierScheduleQueryScheduler.class + .getAnnotation(ConditionalOnProperty.class); + + assertThat(condition).isNotNull(); + assertThat(condition.prefix()).isEqualTo("baiyue.carrier-schedule.scheduler"); + assertThat(condition.name()).containsExactly("enabled"); + assertThat(condition.havingValue()).isEqualTo("true"); + assertThat(condition.matchIfMissing()).isFalse(); + } + @Test void declaresOneConfigurableDueScannerRatherThanDirectCarrierCronCalls() throws Exception { Scheduled[] schedules = CarrierScheduleQueryScheduler.class.getMethod("poll").getAnnotationsByType(Scheduled.class); diff --git a/scripts/acceptance-finance-full-contract.test.mjs b/scripts/acceptance-finance-full-contract.test.mjs index 0437ffede..385d3705c 100644 --- a/scripts/acceptance-finance-full-contract.test.mjs +++ b/scripts/acceptance-finance-full-contract.test.mjs @@ -4,19 +4,29 @@ import fs from 'node:fs' import http from 'node:http' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { AcceptanceRun } from './admin-test-env-acceptance.mjs' +import { AcceptanceRun, normalizeModuleOrder } from './admin-test-env-acceptance.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'admin-test-env-acceptance.mjs'), 'utf8') -test('默认全量按 fixture、reconciliation、settlement、downloads 顺序接入且不包含旧 finance', () => { +test('默认全量按新财务链执行直接收付款隔离且不包含旧 finance', () => { const match = source.match(/const CONFIGURED_MODULES = \[(.*?)\]/s) assert.ok(match) const modules = [...match[1].matchAll(/'([^']+)'/g)].map((item) => item[1]) const financeStart = modules.indexOf('finance-full-fixtures') - assert.deepEqual(modules.slice(financeStart, financeStart + 4), ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads']) + assert.deepEqual(modules.slice(financeStart, financeStart + 5), [ + 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-direct-paid-isolation', + 'finance-settlement-full', 'finance-downloads', + ]) assert.equal(modules.includes('finance'), false) assert.match(source, /fullFinanceChain/) + + const normalized = normalizeModuleOrder(modules) + const normalizedFinanceStart = normalized.indexOf('finance-full-fixtures') + assert.deepEqual(normalized.slice(normalizedFinanceStart, normalizedFinanceStart + 5), [ + 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-direct-paid-isolation', + 'finance-settlement-full', 'finance-downloads', + ]) }) test('multipart request preserves FormData boundary and unified response evidence', () => { diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index ff537ff9f..200a7a933 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' -const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'firstleg-container-fee-allocation', 'finance-direct-paid-isolation', 'phase4-overseas-finance-cross-region', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-direct-paid-isolation', 'finance-settlement-full', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'customer-operating-statistics'] const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitting-verify', 'manual-preload-push-fixture', 'manual-preload-push-verify', 'manual-pda-ready-fixture', 'manual-pda-ready-verify', @@ -188,7 +188,8 @@ export function normalizeModuleOrder(modules) { const missing = prerequisites.filter((name) => !normalized.includes(name)) if (missing.length > 0) normalized.splice(targetIndex, 0, ...missing) } - const financeChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'] + const financeChain = ['finance-full-fixtures', 'finance-reconciliation-full', + 'finance-direct-paid-isolation', 'finance-settlement-full'] const firstFinanceIndex = normalized.findIndex((name) => financeChain.includes(name)) if (firstFinanceIndex >= 0) { const prerequisites = ['merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading'] @@ -912,11 +913,26 @@ export class AcceptanceRun { async findAvailablePhone(module = 'customers') { this.phoneSequence = Number(this.phoneSequence || 0) + 1 - const markerDigits = this.marker.replace(/\D/g, '').slice(-7).padStart(7, '0') - const phone = `18${markerDigits}${String(this.phoneSequence % 100).padStart(2, '0')}` - this.addStep(module, '生成本轮隔离测试联系电话', null, /^1\d{10}$/.test(phone), - `sequence=${this.phoneSequence}`) - return phone + for (let attempt = 0; attempt < 100; attempt += 1) { + const seed = `${this.marker}:${this.phoneSequence}:${attempt}` + let hash = 2166136261 + for (const character of seed) { + hash ^= character.charCodeAt(0) + hash = Math.imul(hash, 16777619) >>> 0 + } + const phone = `18${String(hash).padStart(9, '0').slice(-9)}` + const response = await this.request('GET', + `/api/platform/v1/iam/tenants/administrator-phone-availability?phone=${encodeURIComponent(phone)}`) + const available = response?.http === 200 && String(response?.body?.code) === '200' + && response?.body?.data === true + if (!available) continue + this.addStep(module, '生成本轮隔离测试联系电话', response, true, + `sequence=${this.phoneSequence}, attempts=${attempt + 1}`) + return phone + } + this.addStep(module, '生成本轮隔离测试联系电话', null, false, + `sequence=${this.phoneSequence}, attempts=100;未找到可用手机号`, { deploymentBlocker: true }) + throw new Error('未找到可用的隔离测试手机号') } async runCustomerDimensionRules(tenantId) { diff --git a/scripts/admin-test-env-modules/finance-direct-paid-isolation-contract.test.mjs b/scripts/admin-test-env-modules/finance-direct-paid-isolation-contract.test.mjs index a800d9a68..12e68cc92 100644 --- a/scripts/admin-test-env-modules/finance-direct-paid-isolation-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-direct-paid-isolation-contract.test.mjs @@ -4,6 +4,13 @@ import test from 'node:test' import { buildDirectPaidBody, findDirectPaidCost, findOpenSettlement } from './finance-direct-paid-isolation.mjs' const source = await readFile(new URL('./finance-direct-paid-isolation.mjs', import.meta.url), 'utf8') +const acceptance = await readFile(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') + +test('直接收付款幂等在全量结清模块消费 AR/AP 之前执行', () => { + const modules = acceptance.match(/const CONFIGURED_MODULES = \[([^\n]+)\]/)?.[1] || '' + assert.ok(modules.indexOf("'finance-reconciliation-full'") < modules.indexOf("'finance-direct-paid-isolation'")) + assert.ok(modules.indexOf("'finance-direct-paid-isolation'") < modules.indexOf("'finance-settlement-full'")) +}) test('DIRECT_PAID 请求只绑定客户、仓库、供应商且不伪造业务关联', () => { const body = buildDirectPaidBody('RUN-001', { customerId: '101', customerName: '客户A' }, diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index 1fa71c449..cf0d110f6 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -24,6 +24,9 @@ test('商户夹具通过正式 File 和 IAM 审核流程建立下单资格', () assert.match(fixture, /MERCHANT_TEST_USERNAME/) assert.match(fixture, /MERCHANT_TEST_PASSWORD/) assert.match(fixture, /context\.options\?\.customerName/) + assert.match(acceptance, /administrator-phone-availability\?phone=/) + assert.match(acceptance, /attempt < 100/) + assert.doesNotMatch(acceptance, /phoneSequence % 10/) assert.doesNotMatch(fixture, /internal\/iam|UPDATE\s+iam_|INSERT\s+INTO\s+iam_/i) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs index 57b7df659..619c25d38 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs @@ -34,7 +34,7 @@ function fixture() { scenarios: [], calls, createdResources: { - merchantQualificationFixture: { tenantId: '1001' }, + merchantQualificationFixture: { tenantId: '1999' }, phase4OverseasBusiness: { purchase: { orderNo: 'PO-1', customerId: '1101' }, truck: { orderNo: 'TK-1', customerId: '1201' }, @@ -145,9 +145,18 @@ test('five fee types traverse Lastleg lifecycle and reach Finance through real H for (const field of ['businessId', 'orderId', 'inboundId', 'inboundCode', 'containerId', 'containerBookingId', 'containerNo']) { assert.equal(warehouseCreate.body[field], null, field) } - assert.equal(warehouseCreate.body.customerId, '1001') + assert.equal(warehouseCreate.body.customerId, '1999') assert.equal(warehouseCreate.body.warehouseId, '9001') + for (const [path, customerId] of [ + ['/api/platform/v1/last-leg/overseas-procurements', '1101'], + ['/api/platform/v1/last-leg/us-truck-orders', '1201'], + ['/api/platform/v1/last-leg/local-looseboard-orders', '1301'], + ]) { + const lookup = context.calls.find((call) => call.method === 'GET' && call.path.startsWith(`${path}?`)) + assert.equal(new URLSearchParams(lookup.path.split('?')[1]).get('customerId'), customerId) + } + const creates = context.calls.filter((call) => call.method === 'POST' && call.path === '/api/platform/v1/last-leg/finance-fee-sources') assert.equal(creates.length, 12)