396 lines
19 KiB
PHP
396 lines
19 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 $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(),
|
||
]);
|
||
}
|
||
}
|