[ 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', ], 'upstream_mirrors.connection' => 'business_mirror']); DB::purge('business_mirror'); $schema = Schema::connection('business_mirror'); $schema->create('store_report_import_states', function (Blueprint $table): void { $table->string('dataset_key')->primary(); $table->string('active_generation')->nullable(); $table->dateTime('initial_completed_at')->nullable(); }); $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_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); }); // 销量来源:业绩明细「收入+优惠+冲减+退款金额」按店铺 + 发生日期的预汇总。 $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); }); $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(); }); DB::connection('business_mirror')->table('store_report_import_states')->insert([ ['dataset_key' => StoreReportRepository::LIFECYCLES_DATASET, 'active_generation' => 'lifecycle-live', 'initial_completed_at' => now()], ['dataset_key' => StoreReportRepository::DAILY_DATASET, 'active_generation' => $this->dailyGeneration, 'initial_completed_at' => now()], ['dataset_key' => StoreReportRepository::PAID_SALES_DATASET, 'active_generation' => $this->paidGeneration, 'initial_completed_at' => now()], ]); $this->repository = new StoreReportRepository(app('db')); } protected function tearDown(): void { Carbon::setTestNow(); DB::purge('business_mirror'); parent::tearDown(); } public function test_initial_data_splits_shop_periods_and_income_at_change_day(): void { config(['store_report_initial_periods.shops' => [ '店铺A' => [ 'previous_company' => '主体甲', 'company' => '主体乙', 'changed_at' => '2026-07-10', ], ]]); $this->insertBill('店铺A', '2026-06-30', 9); $this->insertBill('店铺A', '2026-07-01', 100); $this->insertBill('店铺A', '2026-07-09', 60); $this->insertBill('店铺A', '2026-07-10', 70); $this->insertBill('店铺A', '2026-08-01', 80); $this->insertBill('店铺A', '2027-01-01', 5); $rows = $this->repository->rows([ ['erp_store_name' => '店铺A', 'store_name' => '前台店铺A', 'store_id' => 'SHOP-001', 'company_name' => '旧主体', 'store_status' => '0'], ], 2026); $this->assertSame(['主体甲', '主体乙'], array_column($rows, 'entity')); // 变更日前一天(07-09)仍归老主体,变更日(07-10)当天归新主体,窗口前的 06-30 不计入。 $this->assertSame([160.0, 155.0], array_column($rows, 'period_sales')); $this->assertSame([0.0, 0.0], array_column($rows, 'sales_05')); $this->assertSame([160.0, 70.0], array_column($rows, 'sales_07')); $this->assertSame([0.0, 80.0], array_column($rows, 'sales_08')); $this->assertSame(['2026-07-01', '2026-07-10'], array_column($rows, 'effective_at')); $this->assertSame(['2026-07-09', '存续中'], array_column($rows, 'invalid_at')); $this->assertSame(['开店中', '开店中'], array_column($rows, 'status')); $this->assertSame(['前台店铺A', '前台店铺A'], array_column($rows, 'store_name')); $this->assertSame(['SHOP-001', 'SHOP-001'], array_column($rows, 'store_id')); } public function test_store_without_initial_data_uses_store_row_company_from_initial_date(): void { $this->insertBill('店铺A', '2026-05-03', 25.5); $this->insertBill('店铺A', '2026-07-05', 30); $rows = $this->repository->rows([ ['erp_store_name' => '店铺A', 'company_name' => '公司表主体', 'store_status' => '0', 'opened_at' => '2026-05-01'], ], 2026); $this->assertCount(1, $rows); $this->assertSame(30.0, $rows[0]['period_sales']); $this->assertSame(30.0, $rows[0]['sales_07']); $this->assertSame('公司表主体', $rows[0]['entity']); $this->assertSame('2026-07-01', $rows[0]['effective_at']); $this->assertSame('存续中', $rows[0]['invalid_at']); $this->assertCount(1, $this->repository->rows([['erp_store_name' => '店铺A']], 2026, ['sales_07' => '30.00'])); $this->assertCount(0, $this->repository->rows([['erp_store_name' => '店铺A']], 2026, ['sales_07' => '130'])); } public function test_store_name_sort_is_accepted_and_applied(): void { request()->merge(['sort' => 'store_name', 'direction' => 'asc']); $filters = StoreReportRepository::requestFilters(); $rows = $this->repository->rows([ ['erp_store_name' => '店铺A', 'store_name' => 'Z店'], ['erp_store_name' => '店铺B', 'store_name' => 'A店'], ], 2026, $filters); $this->assertSame(['A店', 'Z店'], array_column($rows, 'store_name')); } public function test_closed_store_shows_dash_for_period_columns_but_keeps_income(): void { $this->insertBill('店铺A', '2026-07-05', 42); $rows = $this->repository->rows([ ['erp_store_name' => '店铺A', 'company_name' => '主体甲', 'store_status' => '6'], ], 2026); $this->assertCount(1, $rows); $this->assertSame('关店', $rows[0]['status']); $this->assertSame('-', $rows[0]['effective_at']); $this->assertSame('-', $rows[0]['invalid_at']); $this->assertSame('主体甲', $rows[0]['entity']); $this->assertSame(42.0, $rows[0]['period_sales']); } public function test_crawler_change_appends_period_and_ignores_repeated_company(): void { $this->insertChange('店铺B', '主体新', '2026-08-20'); $this->insertChange('店铺B', '主体新', '2026-08-25'); $this->insertBill('店铺B', '2026-08-19', 10); $this->insertBill('店铺B', '2026-08-20', 20); $rows = $this->repository->rows([ ['erp_store_name' => '店铺B', 'company_name' => '主体旧', 'store_status' => '0'], ], 2026); $this->assertSame(['主体旧', '主体新'], array_column($rows, 'entity')); $this->assertSame(['2026-07-01', '2026-08-20'], array_column($rows, 'effective_at')); $this->assertSame(['2026-08-19', '存续中'], array_column($rows, 'invalid_at')); $this->assertSame([10.0, 20.0], array_column($rows, 'period_sales')); } public function test_crawler_change_uses_system_entity_as_base_when_row_shows_crawler_company(): void { DB::connection('business_mirror')->table('ops_fys_shop_info')->insert([ 'erpShopName' => '店铺C', 'unifiedSocialCreditCode' => 'CODE-C', 'shopState' => '0', ]); DB::connection('business_mirror')->table('ops_fys_company_info')->insert([ 'unifiedSocialCreditCode' => 'CODE-C', 'businessLicenseName' => '主体旧', ]); $this->insertChange('店铺C', '主体新', '2026-09-29', 'CODE-NEW'); DB::connection('business_mirror')->table('ops_fys_company_info')->insert([ 'unifiedSocialCreditCode' => 'CODE-NEW', 'businessLicenseName' => '主体新', ]); $this->insertBill('店铺C', '2026-09-28', 10); $this->insertBill('店铺C', '2026-09-29', 20); // 店铺基础信息页按爬虫优先展示主体新,报表期间仍以系统主体为基数拆分。 $rows = $this->repository->rows([ ['erp_store_name' => '店铺C', 'company_name' => '主体新', 'store_status' => '0'], ], 2026); $this->assertSame(['主体旧', '主体新'], array_column($rows, 'entity')); $this->assertSame([10.0, 20.0], array_column($rows, 'period_sales')); } public function test_unfinished_shared_daily_summary_is_reported_instead_of_returning_misleading_zeroes(): void { DB::connection('business_mirror')->table('store_report_import_states') ->where('dataset_key', StoreReportRepository::DAILY_DATASET) ->update(['active_generation' => null, 'initial_completed_at' => null]); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('store_report_dataset_not_ready'); $this->repository->rows([['erp_store_name' => '店铺A']], 2026); } public function test_sales_use_performance_details_amount_components_and_income_keeps_bill_summary(): void { Carbon::setTestNow('2026-09-15 10:00:00'); // 同一店铺同一天:账单收入 100,业绩明细四项金额合计 88 —— 销量取合计、收入取账单。 $this->insertIncome('店铺A', '2026-07-05', 100); $this->insertSales('店铺A', '2026-07-05', 88); $this->insertSales('店铺A', '2026-08-03', 5); $rows = $this->repository->rows([ ['erp_store_name' => '店铺A', 'company_name' => '主体甲', 'store_status' => '0'], ], 2026); $this->assertCount(1, $rows); $this->assertSame(93.0, $rows[0]['period_sales']); $this->assertSame(88.0, $rows[0]['sales_07']); $this->assertSame(5.0, $rows[0]['sales_08']); $this->assertSame(100.0, (float) $rows[0]['income_07']); $this->assertSame(100.0, (float) $rows[0]['rolling_12m_income']); } public function test_unfinished_sales_summary_is_reported_instead_of_returning_misleading_zeroes(): void { DB::connection('business_mirror')->table('store_report_import_states') ->where('dataset_key', StoreReportRepository::PAID_SALES_DATASET) ->update(['active_generation' => null, 'initial_completed_at' => null]); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('store_report_dataset_not_ready'); $this->repository->rows([['erp_store_name' => '店铺A']], 2026); } public function test_upstream_business_information_exposes_shared_store_report_rows(): void { $this->insertBill('店铺A', '2026-07-05', 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' => '0', ]]); $accounts = Mockery::mock(StoreAccountMirrorRepository::class); $accounts->shouldReceive('ownershipByStoreName')->andReturn([]); $service = new UpstreamBusinessInformation( Mockery::mock(UpstreamQueryCacheFactory::class), $mirror, Mockery::mock(InstallationDetailMirrorRepository::class), $this->repository, $accounts, ); $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_store_report_hides_sales_months_without_data_by_default(): void { Carbon::setTestNow('2026-09-15 10:00:00'); $this->insertBill('店铺A', '2026-07-05', 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' => '0', ]]); $accounts = Mockery::mock(StoreAccountMirrorRepository::class); $accounts->shouldReceive('ownershipByStoreName')->andReturn([]); $service = new UpstreamBusinessInformation( Mockery::mock(UpstreamQueryCacheFactory::class), $mirror, Mockery::mock(InstallationDetailMirrorRepository::class), $this->repository, $accounts, ); $paginator = $service->paginate('store-management', 'store-report', 50); // 只有 7 月有业绩,其余月份列默认隐藏。 $this->assertSame( ['sales_01', 'sales_02', 'sales_03', 'sales_04', 'sales_05', 'sales_06', 'sales_08', 'sales_09', 'sales_10', 'sales_11', 'sales_12'], $this->app['request']->attributes->get('store-report.default_hidden_columns'), ); $this->assertSame(1, $paginator->total()); $this->assertSame(60.0, (float) $paginator->items()[0]->sales_07); } public function test_subject_operating_report_aggregates_income_by_entity(): void { Carbon::setTestNow('2026-09-01 10:00:00'); $this->insertBill('店铺A', '2026-07-10', 20); $this->insertBill('店铺A', '2026-08-10', 30); $this->insertBill('店铺B', '2026-08-11', 40); $mirror = Mockery::mock(BusinessInformationMirrorRepository::class); $mirror->shouldReceive('ready')->with('store-information.read')->andReturnTrue(); $mirror->shouldReceive('allStores')->times(3)->andReturn([ ['erp_store_name' => '店铺A', 'company_name' => '主体甲', 'store_status' => '0'], ['erp_store_name' => '店铺B', 'company_name' => '主体甲', 'store_status' => '0'], ]); $mirror->shouldReceive('ready')->with('company-information.read')->andReturnTrue(); $mirror->shouldReceive('companiesByNames')->andReturn([]); $accounts = Mockery::mock(StoreAccountMirrorRepository::class); $accounts->shouldReceive('ownershipByStoreName')->andReturn([]); $service = new UpstreamBusinessInformation( Mockery::mock(UpstreamQueryCacheFactory::class), $mirror, Mockery::mock(InstallationDetailMirrorRepository::class), $this->repository, $accounts, ); $rows = $service->paginate('company-management', 'subject-operating-report', 50)->items(); $this->assertCount(1, $rows); $this->assertSame('主体甲', $rows[0]->entity); $this->assertSame(90.0, $rows[0]->rolling_12m_income); $this->assertSame(20.0, $rows[0]->income_07); $this->assertSame(70.0, $rows[0]->income_08); request()->merge(['income_08' => '70.00']); $this->assertSame(1, $service->paginate('company-management', 'subject-operating-report', 50)->total()); request()->merge(['income_08' => '71.00']); $this->assertSame(0, $service->paginate('company-management', 'subject-operating-report', 50)->total()); } private function insertBill(string $store, string $occurredOn, float $income): void { $this->insertIncome($store, $occurredOn, $income); // 销量与收入同源不同表:默认按同额写入业绩明细的四项金额合计,需要区分口径的用例单独调用。 $this->insertSales($store, $occurredOn, $income); } /** 平台账单日汇总的收入行(主体维度经营报表的「收入」列)。 */ private function insertIncome(string $store, string $occurredOn, float $income): void { DB::connection('business_mirror')->table('store_report_daily_rows')->insert([ 'generation' => $this->dailyGeneration, 'store_name' => $store, 'occurred_on' => $occurredOn, 'income' => $income, 'expense' => 0, ]); } /** 业绩明细「收入+优惠+冲减+退款金额」按店铺 + 发生日期的预汇总行。 */ private function insertSales(string $store, string $occurredOn, float $amount): void { DB::connection('business_mirror')->table('store_report_paid_sales_rows')->insert([ 'generation' => $this->paidGeneration, 'store_name' => $store, 'occurred_on' => $occurredOn, 'paid_amount' => $amount, 'order_count' => 1, ]); } private function insertChange(string $store, string $company, string $observedOn, ?string $taxNo = null): void { DB::connection('business_mirror')->table('store_report_shop_period_changes')->insert([ 'erp_store_name' => $store, 'company_name' => $company, 'unified_social_credit_code' => $taxNo, 'observed_on' => $observedOn, 'source' => 'crawl_shop_company', 'business_key' => hash('sha256', implode('|', [$store, $company, $observedOn])), 'created_at' => now(), 'updated_at' => now(), ]); } }