CaiWuXiTong/tests/Unit/EcommerceFinance/StoreReportRepositoryTest.php

396 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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 $dailyGeneration = '22222222-2222-4222-8222-222222222222';
private string $paidGeneration = '33333333-3333-4333-8333-333333333333';
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_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(),
]);
}
}