CaiWuXiTong/tests/Unit/EcommerceFinance/StoreReportRepositoryTest.php

214 lines
10 KiB
PHP

<?php
namespace Tests\Unit\EcommerceFinance;
use App\Business\UpstreamBusinessInformation;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository;
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreAccountMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReportRepository;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Mockery;
use RuntimeException;
use Tests\TestCase;
/**
* 店铺维度报表数据组合:共享生命周期和日汇总的拆行与收入归属。
*/
class StoreReportRepositoryTest extends TestCase
{
private StoreReportRepository $repository;
private string $lifecycleGeneration = '11111111-1111-4111-8111-111111111111';
private string $dailyGeneration = '22222222-2222-4222-8222-222222222222';
protected function setUp(): void
{
parent::setUp();
config(['database.connections.business_mirror' => [
'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_lifecycle_rows', function (Blueprint $table): void {
$table->increments('id');
$table->string('generation');
$table->string('business_key');
$table->string('erp_store_name');
$table->string('company_name')->nullable();
$table->dateTime('effective_at')->nullable();
$table->dateTime('expires_at')->nullable();
});
$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);
});
DB::connection('business_mirror')->table('store_report_import_states')->insert([
['dataset_key' => StoreReportRepository::LIFECYCLES_DATASET, 'active_generation' => $this->lifecycleGeneration, 'initial_completed_at' => now()],
['dataset_key' => StoreReportRepository::DAILY_DATASET, 'active_generation' => $this->dailyGeneration, '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_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', 'store_name' => '前台店铺A', 'store_id' => 'SHOP-001', '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(['前台店铺A', '前台店铺A'], array_column($rows, 'store_name'));
$this->assertSame(['SHOP-001', 'SHOP-001'], array_column($rows, 'store_id'));
$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_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_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
{
DB::connection('business_mirror')->table('store_report_daily_rows')->insert([
'generation' => $this->dailyGeneration,
'store_name' => $store,
'occurred_on' => substr($occurredAt, 0, 10),
'income' => $income,
'expense' => 0,
]);
}
private function insertLifecycle(string $store, string $company, ?string $from, ?string $to): void
{
DB::connection('business_mirror')->table('store_report_lifecycle_rows')->insert([
'generation' => $this->lifecycleGeneration,
'business_key' => hash('sha256', implode('|', [$store, (string) $from, (string) $to])),
'erp_store_name' => $store,
'company_name' => $company,
'effective_at' => $from,
'expires_at' => $to,
]);
}
}