['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''], 'upstream_mirrors.connection' => 'business_mirror', ]); DB::purge('business_mirror'); $this->createTables(); DB::connection('business_mirror')->table('store_report_import_states')->insert([ ['dataset_key' => StoreReportRepository::BILLS_DATASET, 'status' => 'succeeded', 'active_generation' => 'bills-v1'], ['dataset_key' => StoreReportRepository::LIFECYCLES_DATASET, 'status' => 'succeeded', 'active_generation' => 'lifecycles-v1'], ]); $this->repository = new StoreReportRepository(app('db')); } protected function tearDown(): void { Carbon::setTestNow(); parent::tearDown(); } public function test_lifecycle_boundaries_split_monthly_income_without_double_counting(): void { $db = DB::connection('business_mirror'); $db->table('store_report_lifecycle_rows')->insert([ $this->lifecycle(1, '店铺A', '主体甲', '2026-05-01', '2026-07-01'), $this->lifecycle(2, '店铺A', '主体乙', '2026-07-01', null), ]); $db->table('store_report_bill_rows')->insert([ $this->bill(1, '店铺A', '2026-04-30 23:59:59', 9), $this->bill(2, '店铺A', '2026-05-01 00:00:00', 100), $this->bill(3, '店铺A', '2026-06-30 23:59:59', 60), $this->bill(4, '店铺A', '2026-07-01 00:00:00', 70), $this->bill(5, '店铺A', '2026-08-01 00:00:00', 80), $this->bill(6, '店铺A', '2027-01-01 00:00:00', 5), ]); $rows = $this->repository->rows([ ['erp_store_name' => '店铺A', 'store_id' => 'STORE-001', 'company_name' => '旧主体', 'store_status' => '3'], ], 2026); $this->assertSame(['主体甲', '主体乙'], array_column($rows, 'entity')); $this->assertSame('STORE-001', $rows[0]['store_id']); $this->assertSame([160.0, 155.0], array_column($rows, 'period_sales')); $this->assertSame([100.0, 0.0], array_column($rows, 'sales_05')); $this->assertSame([0.0, 70.0], array_column($rows, 'sales_07')); $this->assertSame(['关店中', '关店中'], array_column($rows, 'status')); } public function test_failed_refresh_keeps_the_previous_active_generation_readable(): void { DB::connection('business_mirror')->table('store_report_import_states') ->where('dataset_key', StoreReportRepository::BILLS_DATASET) ->update(['status' => 'failed', 'pending_generation' => null]); DB::connection('business_mirror')->table('store_report_bill_rows')->insert( $this->bill(1, '店铺A', '2026-05-03 12:00:00', 25.5), ); $rows = $this->repository->rows([ ['erp_store_name' => '店铺A', 'company_name' => '公司表主体', 'opened_at' => '2026-05-01'], ], 2026); $this->assertSame(25.5, $rows[0]['period_sales']); $this->assertSame(25.5, $rows[0]['sales_05']); $this->assertSame('公司表主体', $rows[0]['entity']); } public function test_missing_first_import_is_reported_instead_of_returning_misleading_zeroes(): void { DB::connection('business_mirror')->table('store_report_import_states') ->where('dataset_key', StoreReportRepository::BILLS_DATASET)->delete(); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('中央同步中'); $this->repository->rows([['erp_store_name' => '店铺A']], 2026); } public function test_upstream_business_information_exposes_shared_store_report_rows(): void { DB::connection('business_mirror')->table('store_report_lifecycle_rows')->insert([ $this->lifecycle(1, '店铺A', '主体甲', '2026-05-01', null), ]); DB::connection('business_mirror')->table('store_report_bill_rows')->insert([ $this->bill(1, '店铺A', '2026-06-30 23:59:59', 60), ]); $mirror = Mockery::mock(BusinessInformationMirrorRepository::class); $mirror->shouldReceive('ready')->with('store-information.read')->andReturnTrue(); $mirror->shouldReceive('allStores')->once()->andReturn([[ 'erp_store_name' => '店铺A', 'company_name' => '公司表主体', 'store_status' => '3', ]]); $service = new UpstreamBusinessInformation( Mockery::mock(UpstreamQueryCacheFactory::class), $mirror, Mockery::mock(InstallationDetailMirrorRepository::class), $this->repository, ); $paginator = $service->paginate('store-management', 'store-report', 50); $this->assertSame(1, $paginator->total()); $this->assertSame('主体甲', $paginator->items()[0]->entity); $this->assertSame(60.0, $paginator->items()[0]->period_sales); } public function test_subject_operating_report_uses_real_recent_and_entity_sales(): void { Carbon::setTestNow('2026-09-01 10:00:00'); DB::connection('business_mirror')->table('store_report_lifecycle_rows')->insert([ $this->lifecycle(1, '店铺A', '主体甲', '2026-01-01', null), $this->lifecycle(2, '店铺B', '主体甲', '2026-01-01', null), ]); DB::connection('business_mirror')->table('store_report_bill_rows')->insert([ $this->bill(1, '店铺A', '2026-06-10 10:00:00', 10), $this->bill(2, '店铺A', '2026-07-10 10:00:00', 20), $this->bill(3, '店铺A', '2026-08-10 10:00:00', 30), $this->bill(4, '店铺B', '2026-08-11 10:00:00', 40), ]); $mirror = Mockery::mock(BusinessInformationMirrorRepository::class); $mirror->shouldReceive('ready')->with('store-information.read')->andReturnTrue(); $mirror->shouldReceive('allStores')->once()->andReturn([ ['erp_store_name' => '店铺A', 'company_name' => '主体甲', 'store_status' => '0'], ['erp_store_name' => '店铺B', 'company_name' => '主体甲', 'store_status' => '0'], ]); $service = new UpstreamBusinessInformation( Mockery::mock(UpstreamQueryCacheFactory::class), $mirror, Mockery::mock(InstallationDetailMirrorRepository::class), $this->repository, ); $rows = $service->paginate('company-management', 'subject-operating-report', 50)->items(); $this->assertCount(2, $rows); $this->assertSame(10.0, $rows[0]->recent_three_month_sales_1); $this->assertSame(20.0, $rows[0]->recent_three_month_sales_2); $this->assertSame(30.0, $rows[0]->recent_three_month_sales_3); $this->assertSame(60.0, $rows[0]->store_sales_12m); $this->assertSame(100.0, $rows[0]->entity_sales_12m); $this->assertSame(100.0, $rows[1]->entity_sales_12m); } 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'); $table->string('active_generation')->nullable(); $table->string('pending_generation')->nullable(); }); $schema->create('store_report_bill_rows', function (Blueprint $table): void { $table->increments('id'); $table->string('generation'); $table->string('store_name'); $table->dateTime('occurred_at')->nullable(); $table->decimal('income', 20, 4)->default(0); }); $schema->create('store_report_lifecycle_rows', function (Blueprint $table): void { $table->increments('id'); $table->string('generation'); $table->string('erp_store_name'); $table->string('company_name')->nullable(); $table->dateTime('effective_at')->nullable(); $table->dateTime('expires_at')->nullable(); }); } private function bill(int $id, string $store, string $time, float $income): array { return ['id' => $id, 'generation' => 'bills-v1', 'store_name' => $store, 'occurred_at' => $time, 'income' => $income]; } private function lifecycle(int $id, string $store, string $company, ?string $from, ?string $to): array { return [ 'id' => $id, 'generation' => 'lifecycles-v1', 'erp_store_name' => $store, 'company_name' => $company, 'effective_at' => $from, 'expires_at' => $to, ]; } }