databasePath = sys_get_temp_dir() . '/store-report-rows-' . uniqid() . '.sqlite'; $pdo = new PDO('sqlite:' . $this->databasePath); $pdo->exec('CREATE TABLE bills ( id INTEGER PRIMARY KEY AUTOINCREMENT, platform TEXT NOT NULL, type TEXT NOT NULL, store_name TEXT NOT NULL, occurred_at TEXT, income NUMERIC NOT NULL DEFAULT 0, expense NUMERIC NOT NULL DEFAULT 0, bill_item TEXT, record_code TEXT NOT NULL, source_file TEXT NOT NULL, raw_json TEXT NOT NULL, imported_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(platform, record_code) )'); $pdo->exec('CREATE TABLE store_lifecycles ( id INTEGER PRIMARY KEY AUTOINCREMENT, erp_store_name TEXT NOT NULL, company_name TEXT, effective_at TEXT, expires_at TEXT, source_file TEXT NOT NULL, raw_json TEXT NOT NULL, imported_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(erp_store_name, effective_at, expires_at) )'); $this->pdo = $pdo; config(['database.connections.bills_sqlite.database' => $this->databasePath]); $this->repository = new StoreReportRepository(app('db')); } protected function tearDown(): void { Carbon::setTestNow(); $this->pdo = null; @unlink($this->databasePath); parent::tearDown(); } public function test_lifecycle_boundaries_split_monthly_income_without_double_counting(): void { $this->insertLifecycle('店铺A', '主体甲', '2026-05-01 00:00:00', '2026-07-01 00:00:00'); $this->insertLifecycle('店铺A', '主体乙', '2026-07-01 00:00:00', null); $this->insertBill('店铺A', '2026-04-30 10:00:00', 9); $this->insertBill('店铺A', '2026-05-01 00:00:00', 100); $this->insertBill('店铺A', '2026-06-30 23:59:59', 60); $this->insertBill('店铺A', '2026-07-01 00:00:00', 70); $this->insertBill('店铺A', '2026-08-01 10:00:00', 80); $this->insertBill('店铺A', '2027-01-01 10:00:00', 5); $rows = $this->repository->rows([ ['erp_store_name' => '店铺A', 'company_name' => '旧主体', 'store_status' => '3'], ], 2026); $this->assertSame(['主体甲', '主体乙'], array_column($rows, 'entity')); // 主体切换日 07-01 的账单只归主体乙(左闭右开),切换前的 04-30 不计入。 $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')); $this->assertSame(['2026-07-01', '存续中'], array_column($rows, 'invalid_at')); } public function test_fallback_row_uses_company_table_subject_and_default_window(): void { // 未匹配生命周期时按默认窗口 [2026-07-01, 2999-01-01) 归属账单。 $this->insertBill('店铺A', '2026-05-03 10:00:00', 25.5); $this->insertBill('店铺A', '2026-07-05 10:00:00', 30); $rows = $this->repository->rows([ ['erp_store_name' => '店铺A', 'company_name' => '公司表主体', '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']); } public function test_missing_sqlite_file_is_reported_instead_of_returning_misleading_zeroes(): void { config(['database.connections.bills_sqlite.database' => sys_get_temp_dir() . '/store-report-missing-' . uniqid() . '.sqlite']); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('店铺账单 SQLite 尚未挂载'); $this->repository->rows([['erp_store_name' => '店铺A']], 2026); } public function test_upstream_business_information_exposes_shared_store_report_rows(): void { $this->insertLifecycle('店铺A', '主体甲', '2026-01-01 00:00:00', null); $this->insertBill('店铺A', '2026-06-30 10:00:00', 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', ]]); $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_subject_operating_report_aggregates_income_by_entity(): void { Carbon::setTestNow('2026-09-01 10:00:00'); $this->insertLifecycle('店铺A', '主体甲', '2026-01-01 00:00:00', null); $this->insertLifecycle('店铺B', '主体甲', '2026-01-01 00:00:00', null); $this->insertBill('店铺A', '2026-06-10 10:00:00', 10); $this->insertBill('店铺A', '2026-07-10 10:00:00', 20); $this->insertBill('店铺A', '2026-08-10 10:00:00', 30); $this->insertBill('店铺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'], ]); $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(100.0, $rows[0]->rolling_12m_income); $this->assertSame(10.0, $rows[0]->income_06); $this->assertSame(20.0, $rows[0]->income_07); $this->assertSame(70.0, $rows[0]->income_08); } private function insertBill(string $store, string $occurredAt, float $income): void { $statement = $this->pdo->prepare( 'INSERT INTO bills (platform, type, store_name, occurred_at, income, expense, record_code, source_file, raw_json) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)', ); $statement->execute(['test', 'income', $store, $occurredAt, $income, uniqid(), 'test', '{}']); } private function insertLifecycle(string $store, string $company, ?string $from, ?string $to): void { $statement = $this->pdo->prepare( 'INSERT INTO store_lifecycles (erp_store_name, company_name, effective_at, expires_at, source_file, raw_json) VALUES (?, ?, ?, ?, ?, ?)', ); $statement->execute([$store, $company, $from, $to, 'test', '{}']); } }