CaiWuXiTong/tests/Feature/Business/FinanceBillShopBalanceFallbackTest.php
92451 0b69f30714 feat: 补录财务账单店铺余额并标记来源
聚水潭缺失店铺按财务账单补录;页面和导出标记数据来源。

PR #552
Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
2026-10-25 17:06:19 +08:00

327 lines
14 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\Feature\Business;
use App\Models\Business\StoreReceivingAccount;
use Illuminate\Support\Carbon;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Mockery;
use Modules\EcommerceFinance\Repositories\FinanceBillBalanceRepository;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use Modules\EcommerceFinance\Services\FinanceBillShopBalanceFallback;
use Modules\EcommerceFinance\Services\JushuitanShopBalanceImporter;
use RuntimeException;
use Tests\Feature\FeatureTestCase;
class FinanceBillShopBalanceFallbackTest extends FeatureTestCase
{
/** @var list<string> */
private array $temporaryFiles = [];
protected function setUp(): void
{
parent::setUp();
DB::connection('business_mirror')
->table(JushuitanShopBalanceRepository::TABLE)
->delete();
$mirror = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
if (! $mirror->hasTable('taobao_alipay_bill_rows')) {
$mirror->create('taobao_alipay_bill_rows', function (Blueprint $table): void {
$table->string('record_code', 191)->primary();
$table->string('store_name')->nullable();
$table->string('alipay_account')->nullable();
$table->dateTime('occurred_at');
$table->decimal('balance_amount', 20, 6)->default(0);
});
} else {
DB::connection('business_mirror')->table('taobao_alipay_bill_rows')->delete();
}
DB::connection()->table('business_store_receiving_accounts')->delete();
}
protected function tearDown(): void
{
foreach ($this->temporaryFiles as $path) {
if (is_file($path)) {
unlink($path);
}
}
parent::tearDown();
}
public function test_finance_bill_fills_only_missing_jushuitan_store_and_marks_source(): void
{
Carbon::setTestNow('2026-10-24 10:00:00');
try {
$jushuitanStore = $this->store('天猫', '聚水潭已有店');
$missingStore = $this->store('天猫', '财务账单补录店');
$this->insertSnapshot($jushuitanStore->erp_store_name, 10000, 'jushuitan');
$finance = Mockery::mock(FinanceBillBalanceRepository::class);
$finance->shouldReceive('latestBalances')
->twice()
->with('taobao', '2026-10-23', [$missingStore->erp_store_name])
->andReturn([$this->financeRow($missingStore->erp_store_name, 12345.67)]);
$this->app->instance(FinanceBillBalanceRepository::class, $finance);
$fallback = $this->app->make(FinanceBillShopBalanceFallback::class);
$first = $fallback->fillMissing('2026-10-23', 'taobao');
$second = $fallback->fillMissing('2026-10-23', 'taobao');
$this->assertSame(1, $first['missing']);
$this->assertSame(1, $first['resolved']);
$this->assertSame(1, $first['filled']);
$this->assertSame(1, $second['filled']);
$this->assertSame(2, DB::connection('business_mirror')
->table(JushuitanShopBalanceRepository::TABLE)
->where('balance_date', '2026-10-23')
->where('source_scope', 'taobao')
->count());
$this->assertSame(1, DB::connection('business_mirror')
->table(JushuitanShopBalanceRepository::TABLE)
->where('data_source', JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE)
->count());
$matches = $this->app->make(JushuitanShopBalanceRepository::class)->matchesForStores(
[$jushuitanStore, $missingStore],
'2026-10-23',
);
$this->assertSame(JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN, $matches[$jushuitanStore->id]['data_source']);
$this->assertSame(JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE, $matches[$missingStore->id]['data_source']);
$this->assertSame(12345.67, $matches[$missingStore->id]['balance']);
} finally {
Carbon::setTestNow();
}
}
public function test_existing_jushuitan_balance_removes_stale_finance_fallback(): void
{
$store = $this->store('京东', '聚水潭恢复店');
$this->insertSnapshot($store->erp_store_name, 8000, 'jushuitan');
$this->insertSnapshot($store->erp_store_name, 7000, 'finance');
$finance = Mockery::mock(FinanceBillBalanceRepository::class);
$finance->shouldNotReceive('latestBalances');
$this->app->instance(FinanceBillBalanceRepository::class, $finance);
$result = $this->app->make(FinanceBillShopBalanceFallback::class)
->fillMissing('2026-10-23', 'jd-wallet');
$this->assertSame(0, $result['missing']);
$this->assertSame(0, DB::connection('business_mirror')
->table(JushuitanShopBalanceRepository::TABLE)
->where('data_source', JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE)
->count());
$match = $this->app->make(JushuitanShopBalanceRepository::class)
->matchesForStores([$store], '2026-10-23')[$store->id];
$this->assertSame(JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN, $match['data_source']);
$this->assertSame(8000.0, $match['balance']);
}
public function test_pinduoduo_without_cumulative_balance_is_not_filled_from_transactions(): void
{
$store = $this->store('拼多多', '拼多多无余额店');
$finance = Mockery::mock(FinanceBillBalanceRepository::class);
$finance->shouldNotReceive('latestBalances');
$this->app->instance(FinanceBillBalanceRepository::class, $finance);
$result = $this->app->make(FinanceBillShopBalanceFallback::class)
->fillMissing('2026-10-23', 'pinduoduo');
$this->assertFalse($result['supported']);
$this->assertSame('finance_bill_has_no_account_balance', $result['reason']);
$this->assertSame(1, $result['missing']);
$this->assertSame(0, $result['filled']);
$this->assertSame(0, DB::connection('business_mirror')
->table(JushuitanShopBalanceRepository::TABLE)
->where('normalized_store_name', JushuitanShopBalanceRepository::normalizeStoreName($store->erp_store_name))
->count());
}
public function test_finance_source_failure_keeps_previous_fallback_rows(): void
{
$store = $this->store('天猫', '财务源失败保留店');
$this->insertSnapshot($store->erp_store_name, 6000, 'finance');
$finance = Mockery::mock(FinanceBillBalanceRepository::class);
$finance->shouldReceive('latestBalances')
->once()
->with('taobao', '2026-10-23', [$store->erp_store_name])
->andThrow(new RuntimeException('source unavailable'));
$this->app->instance(FinanceBillBalanceRepository::class, $finance);
try {
$this->app->make(FinanceBillShopBalanceFallback::class)
->fillMissing('2026-10-23', 'taobao');
$this->fail('Expected source exception.');
} catch (RuntimeException $exception) {
$this->assertSame('source unavailable', $exception->getMessage());
}
$this->assertSame(6000.0, (float) DB::connection('business_mirror')
->table(JushuitanShopBalanceRepository::TABLE)
->where('data_source', JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE)
->value('balance'));
}
public function test_import_command_fills_missing_store_from_finance_bill_and_marks_sources(): void
{
$jushuitanStore = $this->store('天猫', '聚水潭自动补录已有店');
$missingStore = $this->store('天猫', '财务账单自动补录店');
$file = $this->jushuitanJson([$jushuitanStore->erp_store_name]);
$this->insertTaobaoBillRow($missingStore->erp_store_name, 'account-a', '2026-10-22 20:00:00', 12000);
$this->insertTaobaoBillRow($missingStore->erp_store_name, 'account-a', '2026-10-23 23:59:59', 13500);
$this->insertTaobaoBillRow($missingStore->erp_store_name, 'account-b', '2026-10-23 23:59:30', 500);
$this->insertTaobaoBillRow($missingStore->erp_store_name, 'account-a', '2026-10-24 00:00:00', 99999);
$financeBalances = $this->app->make(FinanceBillBalanceRepository::class)
->latestBalances('taobao', '2026-10-23', [$missingStore->erp_store_name]);
$this->assertCount(1, $financeBalances);
$this->assertSame(14000.0, $financeBalances[0]['balance']);
$this->artisan('business:import-jushuitan-shop-balances', [
'path' => $file,
'--balance-date' => '2026-10-23',
'--scope' => 'taobao',
])->assertExitCode(0);
$matches = $this->app->make(JushuitanShopBalanceRepository::class)->matchesForStores(
[$jushuitanStore, $missingStore],
'2026-10-23',
);
$this->assertSame(JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN, $matches[$jushuitanStore->id]['data_source']);
$this->assertSame(JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE, $matches[$missingStore->id]['data_source']);
$this->assertSame(14000.0, $matches[$missingStore->id]['balance']);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'fund-management',
'page' => 'withdrawal-management',
'balance_date' => '2026-10-23',
]))
->assertOk()
->assertSeeText('财务账单补录 1 条')
->assertSee('withdrawal-balance-source is-finance', false)
->assertSee(
'title="数据来源:财务系统;店铺:天猫-财务账单自动补录店;余额日期:2026-10-23"',
false,
);
}
public function test_jushuitan_import_preserves_finance_fallback_rows(): void
{
$financeStore = $this->store('天猫', '财务补录保留店');
$jushuitanStore = $this->store('天猫', '聚水潭刷新店');
$this->insertSnapshot($financeStore->erp_store_name, 8800, 'finance');
$file = $this->jushuitanJson([$jushuitanStore->erp_store_name]);
$result = $this->app->make(JushuitanShopBalanceImporter::class)
->import($file, '2026-10-23', 'taobao');
$this->assertSame(0, $result['replaced']);
$this->assertSame(1, DB::connection('business_mirror')
->table(JushuitanShopBalanceRepository::TABLE)
->where('balance_date', '2026-10-23')
->where('source_scope', 'taobao')
->where('data_source', JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE)
->count());
$this->assertSame(1, DB::connection('business_mirror')
->table(JushuitanShopBalanceRepository::TABLE)
->where('balance_date', '2026-10-23')
->where('source_scope', 'taobao')
->where('data_source', JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN)
->count());
}
private function store(string $platform, string $name): StoreReceivingAccount
{
return StoreReceivingAccount::query()->create([
'company_id' => $this->company->id,
'store_platform' => $platform,
'erp_store_name' => $platform . '-' . $name,
'shop_name' => $platform . '-' . $name,
'store_entity_name' => '余额回填测试主体',
]);
}
private function insertSnapshot(string $storeName, float $balance, string $dataSource): void
{
$normalized = JushuitanShopBalanceRepository::normalizeStoreName($storeName);
DB::connection('business_mirror')->table(JushuitanShopBalanceRepository::TABLE)->insert([
'balance_date' => '2026-10-23',
'source_scope' => str_starts_with($storeName, '京东') ? 'jd-wallet' : 'taobao',
'data_source' => $dataSource,
'source_platform' => JushuitanShopBalanceRepository::sourcePlatform($storeName),
'store_name' => $storeName,
'normalized_store_name' => $normalized,
'platform_account_id' => 'fixture-account',
'balance' => $balance,
'source_row_key' => hash('sha256', $dataSource . '|' . $storeName),
'source_file_name' => 'fixture-' . $dataSource . '.json',
'source_sha256' => hash('sha256', $dataSource . '|' . $storeName . '|sha'),
'imported_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
/** @param list<string> $storeNames */
private function jushuitanJson(array $storeNames): string
{
$path = sys_get_temp_dir() . '/jst-finance-fallback-' . uniqid('', true) . '.json';
$this->temporaryFiles[] = $path;
file_put_contents($path, json_encode([
'fldHeads' => [
['title' => '店铺', 'fld' => '店铺'],
['title' => '平台实际期末余额', 'fld' => '平台实际期末余额'],
],
'dataList' => array_map(
static fn (string $storeName): array => [
'店铺' => $storeName,
'平台实际期末余额' => 5000,
],
$storeNames,
),
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
return $path;
}
private function insertTaobaoBillRow(
string $storeName,
string $account,
string $occurredAt,
float $balance,
): void {
DB::connection('business_mirror')->table('taobao_alipay_bill_rows')->insert([
'record_code' => 'record-' . uniqid('', true),
'store_name' => $storeName,
'alipay_account' => $account,
'occurred_at' => $occurredAt,
'balance_amount' => $balance,
]);
}
/** @return array<string,mixed> */
private function financeRow(string $storeName, float $balance): array
{
return [
'source_scope' => 'taobao',
'source_platform' => '天猫',
'store_name' => $storeName,
'normalized_store_name' => JushuitanShopBalanceRepository::normalizeStoreName($storeName),
'platform_account_id' => '20880001',
'balance' => $balance,
'occurred_at' => '2026-10-23 20:00:00',
'source_row_key' => hash('sha256', 'finance|' . $storeName),
'source_file_name' => '财务系统/店铺收款账户账单/淘系支付宝',
];
}
}