聚水潭缺失店铺按财务账单补录;页面和导出标记数据来源。 PR #552 Co-authored-by: 92451 <924518135@qq.com> Co-committed-by: 92451 <924518135@qq.com>
327 lines
14 KiB
PHP
327 lines
14 KiB
PHP
<?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' => '财务系统/店铺收款账户账单/淘系支付宝',
|
||
];
|
||
}
|
||
}
|