398 lines
20 KiB
PHP
398 lines
20 KiB
PHP
<?php
|
||
|
||
namespace Tests\Feature\Console;
|
||
|
||
use Illuminate\Database\Schema\Blueprint;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Schema;
|
||
use Modules\EcommerceFinance\Repositories\StoreReportRepository;
|
||
use Modules\EcommerceFinance\Repositories\StoreReportUpstreamRepository;
|
||
use Modules\EcommerceFinance\Services\StoreReportMirrorSynchronizer;
|
||
use Tests\TestCase;
|
||
|
||
class StoreReportMirrorSyncTest extends TestCase
|
||
{
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
config([
|
||
'database.connections.business_mirror' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''],
|
||
'upstream_mirrors.connection' => 'business_mirror',
|
||
'upstream_mirrors.store_report.batch_size' => 100,
|
||
'upstream_mirrors.store_report.max_batches_per_run' => 2,
|
||
'upstream_mirrors.store_report.lifecycle_batch_size' => 100,
|
||
'upstream_mirrors.store_report.lifecycle_max_batches_per_run' => 2,
|
||
'upstream_mirrors.store_report.daily_batch_size' => 100,
|
||
'upstream_mirrors.store_report.daily_max_batches_per_run' => 2,
|
||
'upstream_mirrors.store_report.overlap_rows' => 50,
|
||
]);
|
||
DB::purge('business_mirror');
|
||
$this->createTables();
|
||
}
|
||
|
||
public function test_it_automatically_completes_first_sync_and_maps_income_direction(): void
|
||
{
|
||
$upstream = $this->createMock(StoreReportUpstreamRepository::class);
|
||
$upstream->expects($this->once())->method('lifecycleBatch')->with(0, 100)->willReturn([
|
||
['source_id' => 1, 'erp_store_name' => '店铺A', 'unified_social_credit_code' => 'CREDIT-A', 'effective_at' => '2026-01-01', 'company_name' => '主体甲'],
|
||
]);
|
||
$upstream->expects($this->once())->method('billBatch')->with(0, 100)->willReturn([
|
||
$this->bill(10, '1', 120.5),
|
||
$this->bill(11, '-1', 8.2),
|
||
]);
|
||
|
||
$result = (new StoreReportMirrorSynchronizer($upstream, app('db')))->sync(100, 2);
|
||
|
||
$this->assertSame('succeeded', $result['lifecycles']['status']);
|
||
$this->assertSame('succeeded', $result['bills']['status']);
|
||
$this->assertSame('succeeded', $result['daily']['status']);
|
||
$this->assertDatabaseHas('store_report_bill_rows', [
|
||
'source_id' => 10, 'store_name' => '店铺A', 'income' => 120.5, 'expense' => 0,
|
||
], 'business_mirror');
|
||
$this->assertDatabaseHas('store_report_bill_rows', [
|
||
'source_id' => 11, 'income' => 0, 'expense' => 8.2,
|
||
], 'business_mirror');
|
||
$this->assertDatabaseHas('store_report_import_states', [
|
||
'dataset_key' => StoreReportRepository::BILLS_DATASET,
|
||
'status' => 'succeeded',
|
||
'cursor_id' => 11,
|
||
'row_count' => 2,
|
||
], 'business_mirror');
|
||
$this->assertNotNull(DB::connection('business_mirror')->table('store_report_import_states')
|
||
->where('dataset_key', StoreReportRepository::BILLS_DATASET)->value('active_generation'));
|
||
$this->assertDatabaseHas('store_report_daily_rows', [
|
||
'store_name' => '店铺A', 'occurred_on' => '2026-08-01',
|
||
'income' => 120.5, 'expense' => 8.2,
|
||
], 'business_mirror');
|
||
}
|
||
|
||
public function test_incremental_sync_reuses_active_generation_and_overlaps_recent_ids(): void
|
||
{
|
||
$this->activeState(StoreReportRepository::LIFECYCLES_DATASET, 'lifecycle-live', 20, 1);
|
||
$this->activeState(StoreReportRepository::BILLS_DATASET, 'bill-live', 100, 1);
|
||
$this->activeState(StoreReportRepository::DAILY_DATASET, 'bill-live', 100, 1);
|
||
DB::connection('business_mirror')->table('store_report_bill_rows')->insert([
|
||
'generation' => 'bill-live', 'source_id' => 101, 'shop_id' => 88,
|
||
'platform' => 'TouTiaoFXGZJ', 'platform_label' => '抖音', 'store_name' => '店铺A',
|
||
'occurred_at' => '2026-08-01 10:00:00', 'income' => 10, 'expense' => 0,
|
||
'bill_item' => '订单收入', 'record_code' => '101', 'source_file' => 'source',
|
||
'row_hash' => 'old', 'source_updated_at' => null, 'imported_at' => now(),
|
||
]);
|
||
DB::connection('business_mirror')->table('store_report_daily_rows')->insert([
|
||
'generation' => 'bill-live', 'store_name' => '店铺A', 'occurred_on' => '2026-08-01',
|
||
'income' => 10, 'expense' => 0, 'updated_at' => now(),
|
||
]);
|
||
$upstream = $this->createMock(StoreReportUpstreamRepository::class);
|
||
$upstream->expects($this->once())->method('lifecycleBatch')->with(0, 100)->willReturn([]);
|
||
$upstream->expects($this->once())->method('billBatch')->with(50, 100)->willReturn([
|
||
$this->bill(101, '1', 33.0),
|
||
]);
|
||
|
||
(new StoreReportMirrorSynchronizer($upstream, app('db')))->sync(100, 2);
|
||
|
||
$this->assertDatabaseHas('store_report_bill_rows', [
|
||
'generation' => 'bill-live', 'source_id' => 101, 'income' => 33,
|
||
], 'business_mirror');
|
||
$this->assertDatabaseHas('store_report_import_states', [
|
||
'dataset_key' => StoreReportRepository::BILLS_DATASET,
|
||
'active_generation' => 'bill-live',
|
||
'cursor_id' => 101,
|
||
'row_count' => 1,
|
||
], 'business_mirror');
|
||
$this->assertDatabaseHas('store_report_daily_rows', [
|
||
'generation' => 'bill-live', 'occurred_on' => '2026-08-01', 'income' => 33,
|
||
], 'business_mirror');
|
||
}
|
||
|
||
public function test_incremental_sync_tracks_inserts_and_deletes_without_counting_the_fact_table(): void
|
||
{
|
||
$this->activeState(StoreReportRepository::LIFECYCLES_DATASET, 'lifecycle-live', 20, 0);
|
||
$this->activeState(StoreReportRepository::BILLS_DATASET, 'bill-live', 100, 2);
|
||
$this->activeState(StoreReportRepository::DAILY_DATASET, 'bill-live', 100, 1);
|
||
$db = DB::connection('business_mirror');
|
||
$db->table('store_report_bill_rows')->insert([
|
||
$this->storedBill(101, 10),
|
||
$this->storedBill(102, 20),
|
||
]);
|
||
$db->table('store_report_daily_rows')->insert([
|
||
'generation' => 'bill-live', 'store_name' => '店铺A', 'occurred_on' => '2026-08-01',
|
||
'income' => 30, 'expense' => 0, 'updated_at' => now(),
|
||
]);
|
||
|
||
$deleted = $this->bill(102, '1', 20);
|
||
$deleted['del_flag'] = 1;
|
||
$upstream = $this->createMock(StoreReportUpstreamRepository::class);
|
||
$upstream->expects($this->once())->method('lifecycleBatch')->with(0, 100)->willReturn([]);
|
||
$upstream->expects($this->once())->method('billBatch')->with(50, 100)->willReturn([
|
||
$this->bill(101, '1', 11),
|
||
$deleted,
|
||
$this->bill(103, '-1', 7),
|
||
]);
|
||
|
||
$db->enableQueryLog();
|
||
$result = (new StoreReportMirrorSynchronizer($upstream, app('db')))->sync(100, 2);
|
||
$queries = $db->getQueryLog();
|
||
$db->disableQueryLog();
|
||
|
||
$this->assertSame(2, $result['bills']['stored_rows']);
|
||
$this->assertDatabaseMissing('store_report_bill_rows', [
|
||
'generation' => 'bill-live', 'source_id' => 102,
|
||
], 'business_mirror');
|
||
$this->assertDatabaseHas('store_report_bill_rows', [
|
||
'generation' => 'bill-live', 'source_id' => 103, 'expense' => 7,
|
||
], 'business_mirror');
|
||
$this->assertDatabaseHas('store_report_import_states', [
|
||
'dataset_key' => StoreReportRepository::BILLS_DATASET, 'row_count' => 2,
|
||
], 'business_mirror');
|
||
$this->assertFalse(collect($queries)->contains(
|
||
static fn (array $query): bool => str_contains(strtolower($query['query']), 'count(*)')
|
||
&& str_contains(strtolower($query['query']), 'store_report_bill_rows'),
|
||
));
|
||
}
|
||
|
||
private function bill(int $id, string $direction, float $amount): array
|
||
{
|
||
return [
|
||
'source_id' => $id, 'shop_id' => 88, 'channel' => 'TouTiaoFXGZJ',
|
||
'fee_time' => '2026-08-01 10:00:00', 'del_flag' => 0,
|
||
'primary_key' => 'PK-' . $id, 'source_updated_at' => '2026-08-01 11:00:00',
|
||
'fee_name' => '订单收入', 'inout_type' => $direction, 'amount' => $amount,
|
||
'shop_name' => '店铺A',
|
||
];
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
private function storedBill(int $id, float $income): array
|
||
{
|
||
return [
|
||
'generation' => 'bill-live', 'source_id' => $id, 'shop_id' => 88,
|
||
'platform' => 'TouTiaoFXGZJ', 'platform_label' => '抖音', 'store_name' => '店铺A',
|
||
'occurred_at' => '2026-08-01 10:00:00', 'income' => $income, 'expense' => 0,
|
||
'bill_item' => '订单收入', 'record_code' => (string) $id, 'source_file' => 'source',
|
||
'row_hash' => 'old-' . $id, 'source_updated_at' => null, 'imported_at' => now(),
|
||
];
|
||
}
|
||
|
||
public function test_it_records_first_seen_crawler_subject_change_once(): void
|
||
{
|
||
$mirror = DB::connection('business_mirror');
|
||
$mirror->table('ops_fys_shop_info')->insert([
|
||
['erpShopName' => '店铺A', 'unifiedSocialCreditCode' => 'CODE-A', 'shopState' => '0'],
|
||
['erpShopName' => '店铺B', 'unifiedSocialCreditCode' => 'CODE-C', 'shopState' => '0'],
|
||
]);
|
||
$mirror->table('ops_fys_company_info')->insert([
|
||
['unifiedSocialCreditCode' => 'CODE-A', 'businessLicenseName' => '主体甲'],
|
||
['unifiedSocialCreditCode' => 'CODE-B', 'businessLicenseName' => '主体乙'],
|
||
['unifiedSocialCreditCode' => 'CODE-C', 'businessLicenseName' => '主体丙'],
|
||
]);
|
||
$mirror->table('ops_crawl_shop_company')->insert([
|
||
// 同一店铺历史最新一行仍是老主体,最新一行抓到新主体。
|
||
['id' => 1, 'shop_name' => '店铺A', 'company_name' => '主体甲', 'tax_no' => 'CODE-A', 'crawl_date' => '2026-09-28'],
|
||
['id' => 2, 'shop_name' => '店铺A', 'company_name' => '主体乙', 'tax_no' => 'CODE-B', 'crawl_date' => '2026-09-29'],
|
||
// 名称笔误但税号相同:归一后仍是主体丙,不产生新期间。
|
||
['id' => 3, 'shop_name' => '店铺B', 'company_name' => '主体丙(笔误)', 'tax_no' => 'CODE-C', 'crawl_date' => '2026-09-29'],
|
||
]);
|
||
|
||
$upstream = $this->createMock(StoreReportUpstreamRepository::class);
|
||
$upstream->method('lifecycleBatch')->willReturn([]);
|
||
$upstream->method('billBatch')->willReturn([]);
|
||
$synchronizer = new StoreReportMirrorSynchronizer($upstream, app('db'));
|
||
|
||
$first = $synchronizer->sync(100, 2);
|
||
$second = $synchronizer->sync(100, 2);
|
||
|
||
$this->assertSame('succeeded', $first['period_changes']['status']);
|
||
$this->assertSame(2, $first['period_changes']['checked']);
|
||
$this->assertSame(1, $first['period_changes']['created']);
|
||
$this->assertSame(0, $second['period_changes']['created']);
|
||
$this->assertSame(1, $mirror->table('store_report_shop_period_changes')->count());
|
||
$this->assertDatabaseHas('store_report_shop_period_changes', [
|
||
'erp_store_name' => '店铺A',
|
||
'company_name' => '主体乙',
|
||
'unified_social_credit_code' => 'CODE-B',
|
||
'observed_on' => '2026-09-29',
|
||
], 'business_mirror');
|
||
}
|
||
|
||
private function activeState(string $dataset, string $generation, int $cursor, int $rows): void
|
||
{
|
||
DB::connection('business_mirror')->table('store_report_import_states')->insert([
|
||
'dataset_key' => $dataset, 'status' => 'succeeded', 'active_generation' => $generation,
|
||
'cursor_id' => $cursor, 'row_count' => $rows, 'initial_completed_at' => now(),
|
||
]);
|
||
}
|
||
|
||
public function test_paid_sales_summary_rebuilds_from_performance_details_only_when_source_changes(): void
|
||
{
|
||
$mirror = DB::connection('business_mirror');
|
||
$mirror->table('store_receipt_order_match_rows')->insert([
|
||
$this->perfRow('k1', '2026-08-02', '店铺A', 100.5),
|
||
$this->perfRow('k2', '2026-08-02', '店铺A', 9.5),
|
||
// 未匹配行没有订单实付,落到 0,不计入销量金额。
|
||
$this->perfRow('k3', '2026-08-03', '店铺A', null),
|
||
$this->perfRow('k4', '2026-08-03', '店铺B', 20),
|
||
]);
|
||
$upstream = $this->createMock(StoreReportUpstreamRepository::class);
|
||
$upstream->method('lifecycleBatch')->willReturn([]);
|
||
$upstream->method('billBatch')->willReturn([]);
|
||
$synchronizer = new StoreReportMirrorSynchronizer($upstream, app('db'));
|
||
|
||
$first = $synchronizer->sync(100, 2);
|
||
$generation = $mirror->table('store_report_paid_sales_rows')->value('generation');
|
||
|
||
$this->assertSame('succeeded', $first['paid_sales']['status']);
|
||
$this->assertTrue($first['paid_sales']['changed']);
|
||
$this->assertDatabaseHas('store_report_paid_sales_rows', [
|
||
'generation' => $generation, 'store_name' => '店铺A', 'occurred_on' => '2026-08-02',
|
||
'paid_amount' => 110, 'order_count' => 2,
|
||
], 'business_mirror');
|
||
$this->assertDatabaseHas('store_report_paid_sales_rows', [
|
||
'generation' => $generation, 'store_name' => '店铺B', 'occurred_on' => '2026-08-03',
|
||
'paid_amount' => 20, 'order_count' => 1,
|
||
], 'business_mirror');
|
||
$this->assertSame(3, $mirror->table('store_report_paid_sales_rows')->count());
|
||
$this->assertDatabaseHas('store_report_import_states', [
|
||
'dataset_key' => StoreReportRepository::PAID_SALES_DATASET, 'status' => 'succeeded', 'row_count' => 3,
|
||
], 'business_mirror');
|
||
|
||
// 业绩明细没有变化:沿用同一版本,不重复全量重建。
|
||
$second = $synchronizer->sync(100, 2);
|
||
$this->assertSame('succeeded', $second['paid_sales']['status']);
|
||
$this->assertFalse($second['paid_sales']['changed']);
|
||
$this->assertSame($generation, $mirror->table('store_report_paid_sales_rows')->value('generation'));
|
||
|
||
// 账期重写后指纹变化:重建并只保留新版本,页面不会读到两个版本。
|
||
$mirror->table('store_receipt_order_match_rows')->insert($this->perfRow('k5', '2026-08-04', '店铺B', 5, '2026-10-16 12:00:00'));
|
||
$third = $synchronizer->sync(100, 2);
|
||
$newGeneration = $mirror->table('store_report_paid_sales_rows')->where('occurred_on', '2026-08-04')->value('generation');
|
||
$this->assertTrue($third['paid_sales']['changed']);
|
||
$this->assertNotSame($generation, $newGeneration);
|
||
$this->assertSame(4, $mirror->table('store_report_paid_sales_rows')->count());
|
||
$this->assertSame(0, $mirror->table('store_report_paid_sales_rows')->where('generation', $generation)->count());
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
private function perfRow(string $key, string $occurredOn, string $store, ?float $paid, string $importedAt = '2026-10-16 10:00:00'): array
|
||
{
|
||
return [
|
||
'report_month' => '2026-08-01', 'platform' => '淘系支付宝', 'row_key' => $key,
|
||
'occurred_date' => $occurredOn, 'store_name' => $store,
|
||
'paid_amount' => $paid, 'imported_at' => $importedAt,
|
||
];
|
||
}
|
||
|
||
private function createTables(): void
|
||
{
|
||
$schema = Schema::connection('business_mirror');
|
||
$schema->create('store_report_import_states', function (Blueprint $table): void {
|
||
$table->string('dataset_key')->primary();
|
||
$table->string('status')->default('pending');
|
||
$table->string('run_mode')->nullable();
|
||
$table->string('active_generation')->nullable();
|
||
$table->string('pending_generation')->nullable();
|
||
$table->unsignedBigInteger('cursor_id')->nullable();
|
||
$table->unsignedBigInteger('row_count')->default(0);
|
||
$table->text('source_files_json')->nullable();
|
||
$table->string('source_fingerprint')->nullable();
|
||
$table->dateTime('last_attempted_at')->nullable();
|
||
$table->dateTime('last_succeeded_at')->nullable();
|
||
$table->dateTime('initial_completed_at')->nullable();
|
||
$table->string('last_error_code')->nullable();
|
||
$table->timestamps();
|
||
});
|
||
$schema->create('store_report_bill_rows', function (Blueprint $table): void {
|
||
$table->increments('id');
|
||
$table->string('generation');
|
||
$table->unsignedBigInteger('source_id');
|
||
$table->unsignedBigInteger('shop_id')->nullable();
|
||
$table->string('platform');
|
||
$table->string('platform_label');
|
||
$table->string('store_name');
|
||
$table->dateTime('occurred_at')->nullable();
|
||
$table->decimal('income', 20, 4);
|
||
$table->decimal('expense', 20, 4);
|
||
$table->string('bill_item')->nullable();
|
||
$table->string('record_code');
|
||
$table->string('source_file');
|
||
$table->string('row_hash');
|
||
$table->dateTime('source_updated_at')->nullable();
|
||
$table->dateTime('imported_at');
|
||
$table->unique(['generation', 'source_id']);
|
||
});
|
||
$schema->create('store_report_lifecycle_rows', function (Blueprint $table): void {
|
||
$table->increments('id');
|
||
$table->string('generation');
|
||
$table->unsignedBigInteger('source_id');
|
||
$table->string('business_key');
|
||
$table->string('erp_store_name');
|
||
$table->string('company_name')->nullable();
|
||
$table->string('unified_social_credit_code')->nullable();
|
||
$table->dateTime('effective_at')->nullable();
|
||
$table->dateTime('expires_at')->nullable();
|
||
$table->string('source_file');
|
||
$table->string('row_hash');
|
||
$table->dateTime('imported_at');
|
||
$table->unique(['generation', 'source_id']);
|
||
});
|
||
$schema->create('store_report_daily_rows', function (Blueprint $table): void {
|
||
$table->increments('id');
|
||
$table->string('generation');
|
||
$table->string('store_name');
|
||
$table->date('occurred_on');
|
||
$table->decimal('income', 20, 4)->default(0);
|
||
$table->decimal('expense', 20, 4)->default(0);
|
||
$table->dateTime('updated_at')->nullable();
|
||
$table->unique(['generation', 'store_name', 'occurred_on']);
|
||
});
|
||
$schema->create('store_report_shop_period_changes', function (Blueprint $table): void {
|
||
$table->increments('id');
|
||
$table->string('erp_store_name');
|
||
$table->string('company_name')->nullable();
|
||
$table->string('unified_social_credit_code')->nullable();
|
||
$table->date('observed_on');
|
||
$table->string('source')->default('crawl_shop_company');
|
||
$table->string('business_key')->unique();
|
||
$table->timestamps();
|
||
});
|
||
// 销量汇总表与它的上游业绩明细表。
|
||
$schema->create('store_report_paid_sales_rows', function (Blueprint $table): void {
|
||
$table->increments('id');
|
||
$table->string('generation');
|
||
$table->string('store_name');
|
||
$table->date('occurred_on');
|
||
$table->decimal('paid_amount', 20, 4)->default(0);
|
||
$table->unsignedInteger('order_count')->default(0);
|
||
$table->dateTime('updated_at')->nullable();
|
||
$table->unique(['generation', 'store_name', 'occurred_on']);
|
||
});
|
||
$schema->create('store_receipt_order_match_rows', function (Blueprint $table): void {
|
||
$table->string('report_month');
|
||
$table->string('platform')->nullable();
|
||
$table->string('row_key');
|
||
$table->date('occurred_date')->nullable();
|
||
$table->string('store_name')->nullable();
|
||
$table->decimal('paid_amount', 20, 4)->nullable();
|
||
$table->dateTime('imported_at');
|
||
$table->primary(['report_month', 'row_key']);
|
||
});
|
||
$schema->create('ops_crawl_shop_company', function (Blueprint $table): void {
|
||
$table->increments('id');
|
||
$table->string('shop_name');
|
||
$table->string('company_name')->nullable();
|
||
$table->string('tax_no')->nullable();
|
||
$table->date('crawl_date');
|
||
});
|
||
$schema->create('ops_fys_shop_info', function (Blueprint $table): void {
|
||
$table->increments('Id');
|
||
$table->string('erpShopName');
|
||
$table->string('unifiedSocialCreditCode')->nullable();
|
||
$table->string('shopState')->nullable();
|
||
});
|
||
$schema->create('ops_fys_company_info', function (Blueprint $table): void {
|
||
$table->increments('Id');
|
||
$table->string('unifiedSocialCreditCode')->nullable();
|
||
$table->string('businessLicenseName')->nullable();
|
||
});
|
||
}
|
||
}
|