供应商管理增加每天08:15完整同步 #302

Merged
limeng merged 1 commits from feature/limeng-supplier-daily-sync into main 2026-10-11 13:57:29 +08:00
8 changed files with 166 additions and 0 deletions

View File

@ -0,0 +1,49 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Modules\EcommerceFinance\Services\BusinessMirrorSynchronizer;
use Throwable;
class SyncSupplierManagementMirror extends Command
{
protected $signature = 'business-mirror:sync-supplier-management';
protected $description = '完整校准供应商管理的档案、合同、联系人和打款资料共享镜像';
public function handle(BusinessMirrorDatasetRegistry $datasets, BusinessMirrorSynchronizer $synchronizer): int
{
if (! (bool) config('upstream_mirrors.refresh_enabled', false)) {
return self::SUCCESS;
}
$failed = false;
foreach ([SupplierManagementMirrorRepository::DATASET, ...array_values(SupplierManagementMirrorRepository::RELATED)] as $key) {
if (! $datasets->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;
}
}

View File

@ -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')

View File

@ -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 定点把该渠道所用

View File

@ -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 定点把该渠道所用

View File

@ -91,6 +91,8 @@
## 同步规则
供应商管理保留现有后台轮转刷新,同时每天北京时间 08:15 通过 `business-mirror:sync-supplier-management` 顺序完整校准供应商档案(含易签宝账号)、合同、联系人和打款资料。生产任务使用现有上游同步锁和 `www-data`,有其他同步占用时排队执行;每批沿用现有行数限制,直到四个数据集分别完成,失败记录在原镜像同步状态中。固定任务随正式发布安装,页面继续显示最近同步时间。
1. 首次全量只能通过 `business-mirror:sync-tables <dataset> --full` 显式启动;失败或中断后使用同一命令继续。
2. 日常任务只处理已经完成首次全量的数据集。存在可靠更新时间的表使用 48 小时重叠窗口;没有可靠更新时间的表按稳定主键追加。`rpt.jst_sku` 由普通商品专用任务每分钟优先处理,每轮默认最多 5 批、每批 1000 行,不再参加通用数据集轮转。
3. 全量校准使用新的同步代次,只有全部完成后才删除镜像中本轮未出现的旧行,因此中途失败不会清空当前可用数据。

View File

@ -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,

View File

@ -0,0 +1,97 @@
<?php
namespace Tests\Feature\Console;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\Carbon;
use Mockery;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Modules\EcommerceFinance\Services\BusinessMirrorSynchronizer;
use RuntimeException;
use Tests\TestCase;
class SupplierManagementMirrorSyncCommandTest extends TestCase
{
private const DATASETS = [
'jiansun.dbo.js_supplier_View',
'jiansun.dbo.js_supplier_contract_View',
'jiansun.dbo.js_supplier_linkers',
'jiansun.dbo.js_supplier_payinfo',
];
public function test_daily_sync_finishes_all_four_datasets_in_order(): void
{
config(['upstream_mirrors.refresh_enabled' => 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();
}
}
}

View File

@ -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,