['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']]); DB::purge('business_mirror'); config(['cache.stores.upstream_shared' => ['driver' => 'array'], 'system_database.mode' => 'shared']); $schema = Schema::connection('business_mirror'); foreach (['store_report_import_states', 'store_report_paid_sales_rows', 'store_receipt_order_match_rows', StoreReportPaidSalesChanges::TABLE] as $table) $schema->dropIfExists($table); (require base_path('database/migrations/2026_09_01_000000_create_store_report_mirror_tables.php'))->up(); $schema->table('store_report_import_states', function (Blueprint $table): void { $table->string('run_mode')->nullable(); $table->timestamp('initial_completed_at')->nullable(); }); (require base_path('database/migrations/2026_10_16_234000_create_store_report_paid_sales_rows.php'))->up(); $schema->create('store_receipt_order_match_rows', function (Blueprint $table): void { $table->increments('id'); $table->string('platform'); $table->date('report_month'); $table->date('occurred_date'); $table->string('store_name'); foreach (['income_amount', 'discount_amount', 'offset_amount', 'default_compensation_amount', 'refund_amount'] as $column) $table->decimal($column, 20, 4)->nullable(); }); (require base_path('database/migrations/2026_10_19_171000_track_paid_sales_dirty_dates.php'))->up(); DB::connection('business_mirror')->table('store_receipt_order_match_rows')->insert([ ['platform' => 'A', 'report_month' => '2026-10-01', 'occurred_date' => '2026-10-01', 'store_name' => '测试店', 'income_amount' => 100, 'discount_amount' => -5, 'offset_amount' => 0, 'default_compensation_amount' => -7, 'refund_amount' => -10], ['platform' => 'B', 'report_month' => '2026-10-01', 'occurred_date' => '2026-10-01', 'store_name' => '测试店', 'income_amount' => 20, 'discount_amount' => 0, 'offset_amount' => 0, 'default_compensation_amount' => 0, 'refund_amount' => 0], ['platform' => 'A', 'report_month' => '2026-09-01', 'occurred_date' => '2026-09-30', 'store_name' => '测试店', 'income_amount' => 40, 'discount_amount' => 0, 'offset_amount' => 0, 'default_compensation_amount' => 0, 'refund_amount' => 0], ]); return new StoreReportMirrorSynchronizer(\Mockery::mock(StoreReportUpstreamRepository::class), app('db')); } public function test_amount_change_without_row_count_change_only_rebuilds_affected_days(): void { $sync = $this->setupSummary(); $db = DB::connection('business_mirror'); $this->assertSame('succeeded', $sync->syncPaidSales()['status']); $untouchedId = $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-09-30')->value('id'); $changes = app(StoreReportPaidSalesChanges::class); $changes->markPeriod('2026-10', 'A'); $db->table('store_receipt_order_match_rows')->where('id', 1)->update(['income_amount' => 120]); $this->assertTrue($sync->syncPaidSales()['changed']); $this->assertSame(118.0, (float) $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-01')->value('paid_amount')); $this->assertSame($untouchedId, $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-09-30')->value('id')); $db->enableQueryLog(); $this->assertFalse($sync->syncPaidSales()['changed']); $this->assertSame([], collect($db->getQueryLog())->filter(fn ($query) => str_contains($query['query'], 'store_receipt_order_match_rows'))->all()); } public function test_date_moves_and_deletions_clear_old_totals_and_include_other_platforms(): void { $sync = $this->setupSummary(); $db = DB::connection('business_mirror'); $sync->syncPaidSales(); $changes = app(StoreReportPaidSalesChanges::class); $changes->markPeriod('2026-10', 'A'); $db->table('store_receipt_order_match_rows')->where('id', 1)->update(['occurred_date' => '2026-10-03']); $changes->markPeriod('2026-10', 'A'); $sync->syncPaidSales(); $this->assertSame(20.0, (float) $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-01')->value('paid_amount')); $this->assertSame(78.0, (float) $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-03')->value('paid_amount')); $changes->markPeriod('2026-10', 'B'); $db->table('store_receipt_order_match_rows')->where('platform', 'B')->delete(); $sync->syncPaidSales(); $this->assertSame(0, $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-01')->count()); $this->assertSame(118.0, (float) $db->table('store_report_paid_sales_rows')->sum('paid_amount')); } public function test_a_new_revision_is_not_lost_when_an_older_snapshot_is_acknowledged(): void { $this->setupSummary(); $changes = app(StoreReportPaidSalesChanges::class); $changes->markDates(['2026-10-01']); $old = $changes->pending(); $changes->markDates(['2026-10-01']); $changes->acknowledge($old); $this->assertCount(1, $changes->pending()); } public function test_active_writer_defers_summary_and_other_writers_without_consuming_changes(): void { $sync = $this->setupSummary(); $sync->syncPaidSales(); $changes = app(StoreReportPaidSalesChanges::class); $changes->markDates(['2026-10-01']); $lock = Cache::store('upstream_shared')->lock('store-report:paid-sales', 3600); $this->assertTrue($lock->get()); try { $this->assertFalse($sync->syncPaidSales()['changed']); $this->assertCount(1, $changes->pending()); $this->artisan('store-receipt:order-match:sync', ['month' => '2026-10'])->assertExitCode(75); } finally { $lock->release(); } $this->assertTrue($sync->syncPaidSales()['changed']); $this->assertSame([], $changes->pending()); } public function test_aggregation_failure_keeps_previous_totals_and_dirty_dates(): void { $sync = $this->setupSummary(); $db = DB::connection('business_mirror'); $sync->syncPaidSales(); app(StoreReportPaidSalesChanges::class)->markDates(['2026-10-01']); $db->listen(function ($query): void { if (str_starts_with($query->sql, 'INSERT INTO store_report_paid_sales_rows')) throw new \RuntimeException('test_aggregate_failure'); }); $this->assertSame('failed', $sync->syncPaidSales()['status']); $this->assertSame(98.0, (float) $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-01')->value('paid_amount')); $this->assertCount(1, app(StoreReportPaidSalesChanges::class)->pending()); } }