CaiWuXiTong/tests/Feature/Business/FinanceLedgerShopDataSynchronizerTest.php
92451 88b3f1c830 fix: 支持财务台账店铺台账重建
支持从最新余额快照清空并重建财务台账店铺表。

PR #623
Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
2026-10-26 18:01:31 +08:00

368 lines
15 KiB
PHP

<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Models\Business\StoreReceivingAccount;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Mockery;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use Modules\EcommerceFinance\Services\FinanceLedgerShopDataSynchronizer;
use Modules\EcommerceFinance\Services\FinanceLedgerTransactionWriter;
use Modules\EcommerceFinance\View\BillingDetailsPageData;
use Tests\Feature\FeatureTestCase;
class FinanceLedgerShopDataSynchronizerTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_shop_balance_uses_latest_balance_on_or_before_previous_night(): void
{
Carbon::setTestNow('2026-10-24 09:00:00');
$connection = DB::connection('business_mirror');
$connection->table('fin_shops')->insert([
'shop_name' => '京东-余额测试店',
'platform' => '京东',
'account_short' => '余额测试账户',
'shop_balance' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$this->insertBalance($connection, '2026-10-22', '京东-余额测试店', 222.22);
$this->insertBalance($connection, '2026-10-23', '京东-余额测试店', 333.33);
$this->insertBalance($connection, '2026-10-24', '京东-余额测试店', 444.44);
$result = app(FinanceLedgerShopDataSynchronizer::class)->syncShopBalances('2026-10-23');
$this->assertSame('2026-10-23', $result['date']);
$this->assertSame(1, $result['matched']);
$shop = $connection->table('fin_shops')->where('shop_name', '京东-余额测试店')->first();
$this->assertSame(333.33, (float) $shop->shop_balance);
$this->assertSame('2026-10-23', substr((string) $shop->shop_balance_date, 0, 10));
}
public function test_balance_sync_adds_confirmed_store_that_is_missing_from_shop_ledger(): void
{
Carbon::setTestNow('2026-10-24 09:00:00');
StoreReceivingAccount::withoutGlobalScopes()->create([
'company_id' => $this->company->id,
'store_platform' => '抖音',
'store_name' => '抖音-待补充店铺',
'store_entity_name' => '补充主体有限公司',
'receiving_method' => '银行卡',
]);
$this->insertBalance(
DB::connection('business_mirror'),
'2026-10-23',
'抖音-待补充店铺',
765.43,
'头条放心购',
);
$result = app(FinanceLedgerShopDataSynchronizer::class)->syncShopBalances('2026-10-23');
$this->assertSame(1, $result['added']);
$shop = DB::connection('business_mirror')->table('fin_shops')
->where('shop_name', '抖音-待补充店铺')
->first();
$this->assertNotNull($shop);
$this->assertSame('抖音小店', $shop->platform);
$this->assertSame('补充主体有限公司', $shop->company_name);
$this->assertNull($shop->account_short);
$this->assertSame(765.43, (float) $shop->shop_balance);
$this->assertSame('2026-10-23', substr((string) $shop->shop_balance_date, 0, 10));
}
public function test_balance_sync_adds_missing_store_even_when_receiving_account_is_unknown(): void
{
Carbon::setTestNow('2026-10-24 09:00:00');
$this->insertBalance(
DB::connection('business_mirror'),
'2026-10-23',
'京喜-待确认店铺',
432.10,
'京喜',
);
$result = app(FinanceLedgerShopDataSynchronizer::class)->syncShopBalances('2026-10-23');
$this->assertSame(1, $result['added']);
$shop = DB::connection('business_mirror')->table('fin_shops')
->where('shop_name', '京喜-待确认店铺')
->first();
$this->assertNotNull($shop);
$this->assertSame('京喜企业店', $shop->platform);
$this->assertSame('待确认', $shop->status);
$this->assertNull($shop->company_name);
$this->assertNull($shop->account_short);
$this->assertNull($shop->payout_account);
$this->assertSame(432.10, (float) $shop->shop_balance);
$this->assertSame('2026-10-23', substr((string) $shop->shop_balance_date, 0, 10));
$this->assertStringContainsString('待确认回款账户', (string) $shop->remark);
}
public function test_rebuild_clears_stale_shop_ledger_and_reloads_latest_snapshot(): void
{
Carbon::setTestNow('2026-10-26 09:00:00');
$connection = DB::connection('business_mirror');
$connection->table('fin_shops')->insert([
'shop_name' => '历史脏数据店',
'platform' => '天猫',
'shop_balance' => 99,
'created_at' => now(),
'updated_at' => now(),
]);
StoreReceivingAccount::withoutGlobalScopes()->create([
'company_id' => $this->company->id,
'store_platform' => '拼多多',
'store_name' => '拼多多-台账重建店',
'store_entity_name' => '台账重建主体有限公司',
'withdrawal_account_name' => '台账重建回款户',
'bank_card_last_four' => '7788',
'store_status' => '正常',
]);
$connection->table('fin_account_map')->insert([
'source' => 'shop',
'source_value' => '拼多多-台账重建店',
'account_short' => '重建账户',
'created_at' => now(),
'updated_at' => now(),
]);
$this->insertBalance(
$connection,
'2026-10-25',
'拼多多-台账重建店',
1588.66,
'拼多多',
);
$result = app(FinanceLedgerShopDataSynchronizer::class)
->syncShopBalances('2026-10-25', true);
$this->assertSame(1, $result['rebuilt']);
$this->assertSame(1, $result['matched']);
$this->assertDatabaseMissing('fin_shops', ['shop_name' => '历史脏数据店'], 'business_mirror');
$shop = $connection->table('fin_shops')->first();
$this->assertSame('拼多多-台账重建店', $shop->shop_name);
$this->assertSame('拼多多', $shop->platform);
$this->assertSame('台账重建主体有限公司', $shop->company_name);
$this->assertSame('重建账户', $shop->account_short);
$this->assertSame('在营', $shop->status);
$this->assertSame('台账重建回款户 7788', $shop->payout_account);
$this->assertSame(1588.66, (float) $shop->shop_balance);
$this->assertSame('2026-10-25', substr((string) $shop->shop_balance_date, 0, 10));
}
public function test_rebuild_without_balance_snapshot_keeps_existing_shop_ledger(): void
{
$connection = DB::connection('business_mirror');
$connection->table('fin_shops')->insert([
'shop_name' => '保留店铺',
'platform' => '天猫',
'shop_balance' => 88,
'created_at' => now(),
'updated_at' => now(),
]);
$result = app(FinanceLedgerShopDataSynchronizer::class)
->syncShopBalances('2026-10-25', true);
$this->assertSame(0, $result['rebuilt']);
$this->assertDatabaseHas('fin_shops', [
'shop_name' => '保留店铺',
'shop_balance' => 88,
], 'business_mirror');
}
public function test_withdrawal_rows_are_mapped_to_shop_account_income_and_are_idempotent(): void
{
Carbon::setTestNow('2026-10-24 09:00:00');
$connection = DB::connection('business_mirror');
$connection->table('fin_shops')->insert([
'shop_name' => '京东-提现测试店',
'platform' => '京东',
'account_short' => '提现测试账户',
'created_at' => now(),
'updated_at' => now(),
]);
$this->bindWithdrawalRows([
[
'record_code' => 'JD-WITHDRAW-1',
'store_name' => '京东-提现测试店',
'platform' => '京东',
'bill_item' => '提现',
'finance_remark' => '提现',
'occurred_at' => '2026-10-23 20:00:00',
'income' => '0.00',
'expense' => '123.45',
],
], 2);
$synchronizer = app(FinanceLedgerShopDataSynchronizer::class);
$first = $synchronizer->sync('2026-10-23', '2026-10-23', false);
$second = $synchronizer->sync('2026-10-23', '2026-10-23', false);
$this->assertSame(1, $first['withdrawals']['written']);
$this->assertSame(1, $second['withdrawals']['written']);
$this->assertDatabaseCount('fin_transactions', 1, 'business_mirror');
$transaction = $connection->table('fin_transactions')->first();
$this->assertSame('shop_bill', $transaction->source);
$this->assertSame('提现测试账户', $transaction->account_short);
$this->assertSame('店铺提现', $transaction->process_type);
$this->assertSame(123.45, (float) $transaction->income);
$this->assertSame(0.0, (float) $transaction->expense);
}
public function test_withdrawal_is_skipped_when_same_cashflow_was_already_synced_from_upstream(): void
{
Carbon::setTestNow('2026-10-24 09:00:00');
$connection = DB::connection('business_mirror');
$connection->table('fin_shops')->insert([
'shop_name' => '京东-重复提现店',
'platform' => '京东',
'account_short' => '重复提现账户',
'created_at' => now(),
'updated_at' => now(),
]);
$connection->table('fin_transactions')->insert([
'txn_date' => '2026-10-23',
'account_short' => '重复提现账户',
'detail' => '上游已同步提现',
'income' => 88.00,
'expense' => 0,
'approval_no' => 'UPSTREAM-1',
'source' => 'shop',
'source_value' => '京东-重复提现店',
'process_type' => '店铺提现',
'row_key' => hash('sha256', 'upstream-duplicate'),
'created_at' => now(),
'updated_at' => now(),
]);
$this->bindWithdrawalRows([
[
'record_code' => 'JD-WITHDRAW-2',
'store_name' => '京东-重复提现店',
'platform' => '京东',
'bill_item' => '提现',
'finance_remark' => '提现',
'occurred_at' => '2026-10-23 21:00:00',
'income' => '0.00',
'expense' => '88.00',
],
]);
$result = app(FinanceLedgerShopDataSynchronizer::class)->sync('2026-10-23', '2026-10-23', false);
$this->assertSame(0, $result['withdrawals']['written']);
$this->assertSame(1, $result['withdrawals']['upstream_duplicates']);
$this->assertSame(0, $connection->table('fin_transactions')->where('source', 'shop_bill')->count());
}
public function test_full_upstream_replacement_preserves_generated_shop_withdrawals(): void
{
$connection = DB::connection('business_mirror');
$connection->table('fin_transactions')->insert([
'txn_date' => '2026-10-23',
'account_short' => '保留提现账户',
'detail' => '页面生成提现',
'income' => 50,
'expense' => 0,
'approval_no' => 'JD:KEEP-1',
'source' => FinanceLedgerTransactionWriter::SHOP_WITHDRAWAL_SOURCE,
'source_value' => '京东-保留提现店',
'process_type' => '店铺提现',
'row_key' => hash('sha256', 'keep-shop-withdrawal'),
'created_at' => now(),
'updated_at' => now(),
]);
app(FinanceLedgerTransactionWriter::class)->replace([
[
'txn_date' => '2026-10-23',
'account_short' => '上游账户',
'detail' => '上游动账',
'income' => 10,
'expense' => 0,
'approval_no' => 'UPSTREAM-2',
'source' => 'dingtalk',
'source_value' => '上游来源',
'process_type' => '费用',
],
], false);
$this->assertDatabaseHas('fin_transactions', [
'source' => FinanceLedgerTransactionWriter::SHOP_WITHDRAWAL_SOURCE,
'approval_no' => 'JD:KEEP-1',
], 'business_mirror');
$this->assertDatabaseHas('fin_transactions', [
'source' => 'dingtalk',
'approval_no' => 'UPSTREAM-2',
], 'business_mirror');
}
private function insertBalance(
$connection,
string $date,
string $storeName,
float $balance,
string $platform = '京东',
): void
{
$connection->table(JushuitanShopBalanceRepository::TABLE)->insert([
'balance_date' => $date,
'source_scope' => 'all',
'source_platform' => $platform,
'store_name' => $storeName,
'normalized_store_name' => JushuitanShopBalanceRepository::normalizeStoreName($storeName),
'platform_account_id' => null,
'balance' => $balance,
'source_row_key' => hash('sha256', $date . '|' . $storeName . '|' . $balance),
'source_file_name' => 'fixture.xlsx',
'source_sha256' => hash('sha256', 'fixture'),
'imported_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
private function bindWithdrawalRows(array $rows, int $calls = 1): void
{
$billing = Mockery::mock(BillingDetailsPageData::class);
$billing->shouldReceive('exportFilters')
->times($calls)
->andReturnUsing(static fn (array $input): array => $input + ['remark' => ['提现']]);
$billing->shouldReceive('exportColumns')
->times($calls)
->andReturn([
'record_code' => ['记录编码', 'text'],
'store_name' => ['店铺', 'text'],
'finance_remark' => ['财务备注', 'text'],
'bill_item' => ['账单项目', 'tag'],
'occurred_at' => ['发生时间', 'text'],
'income' => ['收入金额', 'num'],
'expense' => ['支出金额', 'num'],
]);
$billing->shouldReceive('exportRows')
->times($calls)
->andReturnUsing(static function () use ($rows): \Generator {
yield from $rows;
});
app()->instance(BillingDetailsPageData::class, $billing);
}
}