diff --git a/app/Console/Commands/SyncSupplierManagementMirror.php b/app/Console/Commands/SyncSupplierManagementMirror.php new file mode 100644 index 00000000..a3a25175 --- /dev/null +++ b/app/Console/Commands/SyncSupplierManagementMirror.php @@ -0,0 +1,49 @@ +scheduled($key)) { + $this->warn($key . ':未启用后台同步,已跳过。'); + continue; + } + + try { + // 四个已接入的小数据集顺序校准,每批沿用现有大小,直到完整结束。 + $result = $synchronizer->sync($key, true, null, 0); + if ($result['status'] !== 'succeeded') { + $failed = true; + $this->error($key . ':同步未完成,请检查同步状态。'); + continue; + } + $this->info(sprintf('%s:同步完成,本轮 %d 批,累计处理 %d 行。', $key, $result['batches'], $result['rows'])); + } catch (Throwable $exception) { + $failed = true; + Log::error('供应商管理每日镜像同步失败。', ['dataset' => $key, 'exception' => $exception::class]); + $this->error($key . ':同步失败,请检查同步状态。'); + } + } + + return $failed ? self::FAILURE : self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 5b484366..d7ef3708 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -53,6 +53,12 @@ class Kernel extends ConsoleKernel ->withoutOverlapping(60) ->runInBackground(); + $schedule->command('business-mirror:sync-supplier-management') + ->dailyAt('08:15') + ->timezone('Asia/Shanghai') + ->withoutOverlapping(60) + ->runInBackground(); + // 店铺违规信息预警:每天同步副本库抓取结果到统一表(本地补充字段不覆盖)。 $schedule->command('store-violations:sync') ->dailyAt('05:30') diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index f3d66957..1f26c98c 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -197,6 +197,8 @@ CRON_TZ=Asia/Shanghai 0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1 0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1 0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1 +# 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。 +15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1 */2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1 28 1 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan jushuitan:sync-shipping --days=3 >/dev/null 2>&1 # 头条放心购(店铺收款账户账单 › 抖音账户)资金流水:每天 09:30 定点把该渠道所用 diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index e4056eab..b6bd03a5 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -201,6 +201,8 @@ CRON_TZ=Asia/Shanghai 0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1 0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1 0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1 +# 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。 +15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1 */2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1 28 1 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan jushuitan:sync-shipping --days=3 >/dev/null 2>&1 # 头条放心购(店铺收款账户账单 › 抖音账户)资金流水:每天 09:30 定点把该渠道所用 diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 10197066..587ff703 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -91,6 +91,8 @@ ## 同步规则 +供应商管理保留现有后台轮转刷新,同时每天北京时间 08:15 通过 `business-mirror:sync-supplier-management` 顺序完整校准供应商档案(含易签宝账号)、合同、联系人和打款资料。生产任务使用现有上游同步锁和 `www-data`,有其他同步占用时排队执行;每批沿用现有行数限制,直到四个数据集分别完成,失败记录在原镜像同步状态中。固定任务随正式发布安装,页面继续显示最近同步时间。 + 1. 首次全量只能通过 `business-mirror:sync-tables --full` 显式启动;失败或中断后使用同一命令继续。 2. 日常任务只处理已经完成首次全量的数据集。存在可靠更新时间的表使用 48 小时重叠窗口;没有可靠更新时间的表按稳定主键追加。`rpt.jst_sku` 由普通商品专用任务每分钟优先处理,每轮默认最多 5 批、每批 1000 行,不再参加通用数据集轮转。 3. 全量校准使用新的同步代次,只有全部完成后才删除镜像中本轮未出现的旧行,因此中途失败不会清空当前可用数据。 diff --git a/tests/Feature/Console/BusinessMirrorScheduleTest.php b/tests/Feature/Console/BusinessMirrorScheduleTest.php index 1b525ae5..a3b785fb 100644 --- a/tests/Feature/Console/BusinessMirrorScheduleTest.php +++ b/tests/Feature/Console/BusinessMirrorScheduleTest.php @@ -34,6 +34,7 @@ class BusinessMirrorScheduleTest extends TestCase { config(['upstream_mirrors.refresh_enabled' => true, 'upstream_pipeline.sync.max_datasets' => 2]); $registry = Mockery::mock(BusinessMirrorDatasetRegistry::class); + $registry->shouldReceive('definitionValue')->with(Mockery::type('string'), 'full_reconcile_hours', 0)->andReturn(0); $mirror = Mockery::mock(GenericBusinessMirrorRepository::class); $objects = []; foreach (['routine-a', 'routine-b', 'catchup-z'] as $key) { @@ -67,6 +68,7 @@ class BusinessMirrorScheduleTest extends TestCase $catalog = Mockery::mock(BusinessMirrorDataset::class); $other = Mockery::mock(BusinessMirrorDataset::class); $datasets = Mockery::mock(BusinessMirrorDatasetRegistry::class); + $datasets->shouldReceive('definitionValue')->with(Mockery::type('string'), 'full_reconcile_hours', 0)->andReturn(0); $datasets->shouldReceive('all')->once()->andReturn([ ProductCatalogMirrorRepository::DATASET_KEY => $catalog, 'test.dbo.other' => $other, diff --git a/tests/Feature/Console/SupplierManagementMirrorSyncCommandTest.php b/tests/Feature/Console/SupplierManagementMirrorSyncCommandTest.php new file mode 100644 index 00000000..17be2102 --- /dev/null +++ b/tests/Feature/Console/SupplierManagementMirrorSyncCommandTest.php @@ -0,0 +1,97 @@ + true]); + $registry = Mockery::mock(BusinessMirrorDatasetRegistry::class); + $sync = Mockery::mock(BusinessMirrorSynchronizer::class); + foreach (self::DATASETS as $key) { + $registry->shouldReceive('scheduled')->once()->with($key)->andReturn(true); + $sync->shouldReceive('sync')->once()->with($key, true, null, 0)->ordered() + ->andReturn(['status' => 'succeeded', 'batches' => 3, 'rows' => 1200]); + } + $this->app->instance(BusinessMirrorDatasetRegistry::class, $registry); + $this->app->instance(BusinessMirrorSynchronizer::class, $sync); + $this->artisan('business-mirror:sync-supplier-management')->assertSuccessful(); + } + + public function test_one_failure_does_not_prevent_other_supplier_data_from_refreshing(): void + { + config(['upstream_mirrors.refresh_enabled' => true]); + $registry = Mockery::mock(BusinessMirrorDatasetRegistry::class); + $sync = Mockery::mock(BusinessMirrorSynchronizer::class); + foreach (self::DATASETS as $index => $key) { + $registry->shouldReceive('scheduled')->once()->with($key)->andReturn(true); + $expectation = $sync->shouldReceive('sync')->once()->with($key, true, null, 0)->ordered(); + if ($index === 0) $expectation->andThrow(new RuntimeException('upstream unavailable')); + else $expectation->andReturn(['status' => 'succeeded', 'batches' => 1, 'rows' => 5]); + } + $this->app->instance(BusinessMirrorDatasetRegistry::class, $registry); + $this->app->instance(BusinessMirrorSynchronizer::class, $sync); + $this->artisan('business-mirror:sync-supplier-management') + ->expectsOutputToContain('同步失败')->assertExitCode(1); + } + + public function test_local_refresh_disabled_never_reads_upstream(): void + { + config(['upstream_mirrors.refresh_enabled' => false]); + $registry = Mockery::mock(BusinessMirrorDatasetRegistry::class); + $registry->shouldNotReceive('scheduled'); + $sync = Mockery::mock(BusinessMirrorSynchronizer::class); + $sync->shouldNotReceive('sync'); + $this->app->instance(BusinessMirrorDatasetRegistry::class, $registry); + $this->app->instance(BusinessMirrorSynchronizer::class, $sync); + $this->artisan('business-mirror:sync-supplier-management')->assertSuccessful(); + } + + public function test_disabled_supplier_datasets_are_not_synced(): void + { + config(['upstream_mirrors.refresh_enabled' => true]); + $registry = Mockery::mock(BusinessMirrorDatasetRegistry::class); + $registry->shouldReceive('scheduled')->times(4)->andReturn(false); + $sync = Mockery::mock(BusinessMirrorSynchronizer::class); + $sync->shouldNotReceive('sync'); + $this->app->instance(BusinessMirrorDatasetRegistry::class, $registry); + $this->app->instance(BusinessMirrorSynchronizer::class, $sync); + $this->artisan('business-mirror:sync-supplier-management')->assertSuccessful(); + } + + public function test_schedule_runs_at_0815_shanghai_even_when_app_timezone_is_utc(): void + { + config(['app.installed' => true, 'app.schedule_time' => '09:00', 'app.timezone' => 'UTC']); + $schedule = new Schedule('UTC'); + (new \ReflectionMethod(\App\Console\Kernel::class, 'schedule'))->invoke($this->app->make(Kernel::class), $schedule); + $events = collect($schedule->events()) + ->filter(fn ($event) => str_contains($event->command ?? '', 'business-mirror:sync-supplier-management')); + $this->assertCount(1, $events); + $event = $events->first(); + try { + foreach (['00:14' => false, '00:15' => true, '00:16' => false] as $time => $due) { + Carbon::setTestNow(Carbon::parse('2026-10-12 ' . $time . ':00', 'UTC')); + $this->assertSame($due, $event->isDue($this->app), $time); + } + } finally { + Carbon::setTestNow(); + } + } +} diff --git a/tests/Unit/Deployment/BusinessMirrorScheduleTest.php b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php index 87e2b2f0..256cf2f1 100644 --- a/tests/Unit/Deployment/BusinessMirrorScheduleTest.php +++ b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php @@ -58,6 +58,12 @@ class BusinessMirrorScheduleTest extends TestCase $this->assertStringContainsString("CRON_TZ=Asia/Shanghai\n", $contents, $script); $this->assertStringContainsString('0 0 * * * root', $contents, $script); $this->assertStringContainsString('0 5 * * * root', $contents, $script); + $this->assertStringContainsString( + '15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet', + $contents, + $script, + ); + $this->assertSame(1, substr_count($contents, 'business-mirror:sync-supplier-management --quiet'), $script); $this->assertStringContainsString( 'business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet', $contents,