diff --git a/app/Business/WithdrawalManagementReport.php b/app/Business/WithdrawalManagementReport.php index 687d88ce..ea3a1b7b 100644 --- a/app/Business/WithdrawalManagementReport.php +++ b/app/Business/WithdrawalManagementReport.php @@ -3,6 +3,7 @@ namespace App\Business; use App\Models\Business\StoreReceivingAccount; +use Carbon\CarbonImmutable; use Illuminate\Http\Request; use Illuminate\Support\Collection; use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository; @@ -30,12 +31,13 @@ class WithdrawalManagementReport /** * @return array{ - * filters:array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string}, + * filters:array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string,balanceDate:string}, * platforms:list,paths:list,storeEntities:list,rows:Collection, * balances:array, * reserveStandards:array,withdrawalAmounts:array,compliance:array, * summary:array{source:int,matched:int,ambiguous:int,unmatched:int,not_ready:int}, - * balanceColumnLabel:string,balanceDate:string,sort:array{field:string,direction:string} + * balanceColumnLabel:string,balanceDate:string,latestBalanceDate:?string,earliestBalanceDate:?string, + * sort:array{field:string,direction:string} * } */ public function results(Request $request): array @@ -61,12 +63,16 @@ class WithdrawalManagementReport } $today = now()->startOfDay(); - // 余额快照按导入日期保留 5 个自然日:优先显示最近 5 天内导入的最新余额日期; - // 当天尚未导入新数据时,继续沿用最近一次可用余额,避免页面直接变成待同步。 - $balanceDate = $this->shopBalances->latestDateInRetention($today) - ?? $today->copy()->subDay()->toDateString(); + // 余额快照按日期长期保留:默认显示最新余额日期,也可指定历史日期回看。 + $latestBalanceDate = $this->shopBalances->latestBalanceDate($today); + $balanceDate = $filters['balanceDate'] !== '' + ? $filters['balanceDate'] + : ($latestBalanceDate ?? $today->copy()->subDay()->toDateString()); + $labelDate = $filters['balanceDate'] !== '' + ? CarbonImmutable::parse($balanceDate) + : $today; $rows = $query->orderByDesc('id')->get(); - $balances = $this->shopBalances->matchesForStores($rows, $balanceDate, $today); + $balances = $this->shopBalances->matchesForStores($rows, $balanceDate); $balanceCounts = collect($balances)->countBy( static fn (array $match): string => $match['status'], ); @@ -114,27 +120,35 @@ class WithdrawalManagementReport 'withdrawalAmounts' => $withdrawalAmounts, 'compliance' => $compliance, 'summary' => [ - 'source' => $this->shopBalances->countForDate($balanceDate, $today), + 'source' => $this->shopBalances->countForDate($balanceDate), 'matched' => (int) $balanceCounts->get('matched', 0), 'ambiguous' => (int) $balanceCounts->get('ambiguous', 0), 'unmatched' => (int) $balanceCounts->get('unmatched', 0), 'not_ready' => (int) $balanceCounts->get('not_ready', 0), ], - 'balanceColumnLabel' => $today->format('n月j日') . '店铺余额', + 'balanceColumnLabel' => $labelDate->format('n月j日') . '店铺余额', 'balanceDate' => $balanceDate, + 'latestBalanceDate' => $latestBalanceDate, + 'earliestBalanceDate' => $this->shopBalances->earliestBalanceDate(), 'sort' => $sort, ]; } - /** @return array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string} */ + /** @return array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string,balanceDate:string} */ public function filters(Request $request): array { $clean = static fn (mixed $value, int $length = 80): string => mb_substr(trim((string) $value), 0, $length); $balanceStatus = $clean($request->query('balance_status')); + $balanceDate = $clean($request->query('balance_date'), 10); if (! in_array($balanceStatus, ['matched', 'unmatched', 'ambiguous', 'not_ready'], true)) { $balanceStatus = ''; } + $validDate = preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $balanceDate, $parts) === 1 + && checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1]); + if (! $validDate || $balanceDate > now()->toDateString()) { + $balanceDate = ''; + } return [ 'platform' => $clean($request->query('platform')), @@ -142,6 +156,7 @@ class WithdrawalManagementReport 'storeEntity' => $clean($request->query('store_entity'), 191), 'keyword' => $clean($request->query('keyword'), 191), 'balanceStatus' => $balanceStatus, + 'balanceDate' => $balanceDate, ]; } diff --git a/app/Business/WithdrawalManagementSpreadsheet.php b/app/Business/WithdrawalManagementSpreadsheet.php index 75699b5d..da0df249 100644 --- a/app/Business/WithdrawalManagementSpreadsheet.php +++ b/app/Business/WithdrawalManagementSpreadsheet.php @@ -436,7 +436,7 @@ class WithdrawalManagementSpreadsheet { $today = now()->startOfDay(); - return $this->shopBalances->latestDateInRetention($today) + return $this->shopBalances->latestBalanceDate($today) ?? $today->copy()->subDay()->toDateString(); } diff --git a/app/Console/Commands/ImportJushuitanShopBalances.php b/app/Console/Commands/ImportJushuitanShopBalances.php index 1c8e0920..f8292e56 100644 --- a/app/Console/Commands/ImportJushuitanShopBalances.php +++ b/app/Console/Commands/ImportJushuitanShopBalances.php @@ -32,12 +32,11 @@ class ImportJushuitanShopBalances extends Command } $this->info(sprintf( - '%s:%s 共 %d 条,替换原有 %d 条,清理 5 天前 %d 条。', + '%s:%s 共 %d 条,替换原有 %d 条。', $result['dry_run'] ? '校验完成' : '导入完成', $result['balance_date'], $result['total'], $result['replaced'], - $result['pruned'], )); return self::SUCCESS; diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 7c0a9482..94b8837c 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -4254,6 +4254,8 @@ class Pages extends Controller 'withdrawalBalanceSummary' => $report['summary'], 'withdrawalBalanceColumnLabel' => $report['balanceColumnLabel'], 'withdrawalBalanceDate' => $report['balanceDate'], + 'withdrawalLatestBalanceDate' => $report['latestBalanceDate'], + 'withdrawalEarliestBalanceDate' => $report['earliestBalanceDate'], 'withdrawalSort' => $report['sort'], ]; } diff --git a/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php b/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php index 3fc45116..16626c9c 100644 --- a/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php +++ b/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php @@ -11,12 +11,6 @@ class JushuitanShopBalanceRepository { public const TABLE = 'jushuitan_shop_balance_snapshots'; - /** - * 店铺余额快照按导入日期保留最近 5 个自然日。 - * 页面在这 5 天内取最新余额日期,当天尚未导入时继续沿用最近一次数据。 - */ - public const RETENTION_DAYS = 5; - private readonly ConnectionInterface $connection; public function __construct(DatabaseManager $database) @@ -30,7 +24,7 @@ class JushuitanShopBalanceRepository * @param iterable $stores * @return array */ - public function matchesForStores(iterable $stores, string $balanceDate, ?CarbonInterface $today = null): array + public function matchesForStores(iterable $stores, string $balanceDate): array { $stores = is_array($stores) ? $stores : iterator_to_array($stores); $result = []; @@ -45,7 +39,6 @@ class JushuitanShopBalanceRepository $sourceRows = $this->connection->table(self::TABLE) ->where('balance_date', $balanceDate) - ->where('imported_at', '>=', $this->retentionImportedFrom($today)) ->orderBy('id') ->get([ 'id', 'source_platform', 'store_name', 'normalized_store_name', @@ -105,7 +98,7 @@ class JushuitanShopBalanceRepository return $result; } - public function countForDate(string $balanceDate, ?CarbonInterface $today = null): int + public function countForDate(string $balanceDate): int { if (! $this->hasTable()) { return 0; @@ -113,14 +106,13 @@ class JushuitanShopBalanceRepository return $this->connection->table(self::TABLE) ->where('balance_date', $balanceDate) - ->where('imported_at', '>=', $this->retentionImportedFrom($today)) ->count(); } /** - * 取最近 5 个自然日内导入过的最新余额日期;没有可用导入时返回 null。 + * 取不晚于今天的最新余额日期;没有任何快照时返回 null。 */ - public function latestDateInRetention(?CarbonInterface $today = null): ?string + public function latestBalanceDate(?CarbonInterface $today = null): ?string { if (! $this->hasTable()) { return null; @@ -128,7 +120,6 @@ class JushuitanShopBalanceRepository $today = ($today ?? now())->copy()->startOfDay(); $latest = $this->connection->table(self::TABLE) - ->where('imported_at', '>=', $this->retentionImportedFrom($today)) ->where('balance_date', '<=', $today->toDateString()) ->max('balance_date'); @@ -136,17 +127,17 @@ class JushuitanShopBalanceRepository } /** - * 清理导入时间早于最近 5 个自然日的快照,返回删除行数。 + * 取最早余额日期,供页面日期筛选下限使用;没有快照时返回 null。 */ - public function pruneOutsideRetention(?CarbonInterface $today = null): int + public function earliestBalanceDate(): ?string { if (! $this->hasTable()) { - return 0; + return null; } - return $this->connection->table(self::TABLE) - ->where('imported_at', '<', $this->retentionImportedFrom($today)) - ->delete(); + $earliest = $this->connection->table(self::TABLE)->min('balance_date'); + + return $earliest === null ? null : substr((string) $earliest, 0, 10); } public static function normalizeStoreName(string $value): string @@ -218,8 +209,4 @@ class JushuitanShopBalanceRepository return Schema::connection($this->connection->getName())->hasTable(self::TABLE); } - private function retentionImportedFrom(?CarbonInterface $today = null): CarbonInterface - { - return ($today ?? now())->copy()->startOfDay()->subDays(self::RETENTION_DAYS - 1); - } } diff --git a/modules/EcommerceFinance/Services/JushuitanShopBalanceImporter.php b/modules/EcommerceFinance/Services/JushuitanShopBalanceImporter.php index 05459617..a4528aa5 100644 --- a/modules/EcommerceFinance/Services/JushuitanShopBalanceImporter.php +++ b/modules/EcommerceFinance/Services/JushuitanShopBalanceImporter.php @@ -24,7 +24,7 @@ class JushuitanShopBalanceImporter ); } - /** @return array{balance_date:string,total:int,replaced:int,pruned:int,dry_run:bool} */ + /** @return array{balance_date:string,total:int,replaced:int,dry_run:bool} */ public function import( string $path, ?string $balanceDate = null, @@ -87,7 +87,6 @@ class JushuitanShopBalanceImporter ->where('balance_date', $selectedDate) ->where('source_scope', $sourceScope) ->count(); - $pruned = 0; if (! $dryRun) { $now = now(); @@ -95,7 +94,7 @@ class JushuitanShopBalanceImporter $sourceSha256 = hash_file('sha256', $realPath); $this->connection->transaction(function () use ( - $rows, $selectedDate, $sourceScope, $sourceName, $sourceSha256, $now, &$pruned + $rows, $selectedDate, $sourceScope, $sourceName, $sourceSha256, $now ): void { $this->connection->table(JushuitanShopBalanceRepository::TABLE) ->where('balance_date', $selectedDate) @@ -116,8 +115,6 @@ class JushuitanShopBalanceImporter $chunk, )); } - - $pruned = $this->shopBalances->pruneOutsideRetention($now); }); } @@ -125,7 +122,6 @@ class JushuitanShopBalanceImporter 'balance_date' => $selectedDate, 'total' => count($rows), 'replaced' => $existing, - 'pruned' => $pruned, 'dry_run' => $dryRun, ]; } diff --git a/resources/views/business/pages/fund-management/withdrawal-management.blade.php b/resources/views/business/pages/fund-management/withdrawal-management.blade.php index f0b3d0f6..72778289 100644 --- a/resources/views/business/pages/fund-management/withdrawal-management.blade.php +++ b/resources/views/business/pages/fund-management/withdrawal-management.blade.php @@ -2,11 +2,11 @@ $withdrawalImportPreview = session('withdrawalImportPreview'); $withdrawalOpenImport = is_array($withdrawalImportPreview) || $errors->hasAny(['import', 'preview_token']); $withdrawalFilterQuery = array_filter( - request()->only(['platform', 'withdrawal_path', 'store_entity', 'keyword', 'balance_status']), + request()->only(['platform', 'withdrawal_path', 'store_entity', 'keyword', 'balance_status', 'balance_date']), static fn ($value): bool => filled($value), ); $withdrawalQuery = array_filter( - request()->only(['platform', 'withdrawal_path', 'store_entity', 'keyword', 'balance_status', 'sort', 'direction']), + request()->only(['platform', 'withdrawal_path', 'store_entity', 'keyword', 'balance_status', 'balance_date', 'sort', 'direction']), static fn ($value): bool => filled($value), ); $withdrawalSortUrl = static function (string $field, string $direction) use ($withdrawalFilterQuery): string { @@ -64,6 +64,19 @@ @endif +
+ + +
- @if (request()->hasAny(['platform', 'withdrawal_path', 'store_entity', 'keyword', 'balance_status'])) + @if (request()->hasAny(['platform', 'withdrawal_path', 'store_entity', 'keyword', 'balance_status', 'balance_date'])) 重置 @endif diff --git a/tests/Feature/Business/ManagementCrudTest.php b/tests/Feature/Business/ManagementCrudTest.php index 009edbf1..78625942 100644 --- a/tests/Feature/Business/ManagementCrudTest.php +++ b/tests/Feature/Business/ManagementCrudTest.php @@ -99,6 +99,21 @@ class ManagementCrudTest extends FeatureTestCase 'created_at' => now(), 'updated_at' => now(), ]); + DB::connection('business_mirror')->table(JushuitanShopBalanceRepository::TABLE)->insert([ + 'balance_date' => '2026-09-10', + 'source_scope' => 'all', + 'source_platform' => '天猫', + 'store_name' => '天猫-提现测试店铺', + 'normalized_store_name' => JushuitanShopBalanceRepository::normalizeStoreName('天猫-提现测试店铺'), + 'platform_account_id' => 'fixture-account-history', + 'balance' => 12345.67, + 'source_row_key' => hash('sha256', 'history-' . (string) $store->id), + 'source_file_name' => 'fixture-history.json', + 'source_sha256' => str_repeat('a', 64), + 'imported_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); $this->assertContains('fund-management/withdrawal-management', \App\Utilities\SidebarPermissions::allKeys()); @@ -117,6 +132,11 @@ class ManagementCrudTest extends FeatureTestCase 'page' => 'withdrawal-management', 'store_entity' => '提现测试主体', ])); + $historyResponse = $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'fund-management', + 'page' => 'withdrawal-management', + 'balance_date' => '2026-09-10', + ])); } finally { Carbon::setTestNow(); } @@ -146,6 +166,16 @@ class ManagementCrudTest extends FeatureTestCase ->assertSee('data-finance-table-native', false) ->assertSee('data-withdrawal-column-menu', false) ->assertSee('data-withdrawal-column-trigger', false) + ->assertSee('name="balance_date"', false); + + $historyResponse + ->assertOk() + ->assertSeeText('9月10日店铺余额') + ->assertSeeText('12,345.67') + ->assertDontSeeText('19,895.01') + ->assertSeeText('余额源 1 条'); + + $response ->assertSeeInOrder([ 'data-withdrawal-column="platform"', 'data-withdrawal-column="erp_store_name"', diff --git a/tests/Feature/Business/WithdrawalManagementImportExportTest.php b/tests/Feature/Business/WithdrawalManagementImportExportTest.php index 49584d8e..58b55f24 100644 --- a/tests/Feature/Business/WithdrawalManagementImportExportTest.php +++ b/tests/Feature/Business/WithdrawalManagementImportExportTest.php @@ -366,7 +366,8 @@ class WithdrawalManagementImportExportTest extends FeatureTestCase ]; } $mirror = Mockery::mock(JushuitanShopBalanceRepository::class); - $mirror->shouldReceive('latestDateInRetention')->andReturn('2026-09-23'); + $mirror->shouldReceive('latestBalanceDate')->andReturn('2026-09-23'); + $mirror->shouldReceive('earliestBalanceDate')->andReturn('2026-09-01'); $mirror->shouldReceive('matchesForStores')->andReturn($balances); $mirror->shouldReceive('countForDate')->andReturn(6); $this->app->instance(JushuitanShopBalanceRepository::class, $mirror); @@ -452,7 +453,7 @@ class WithdrawalManagementImportExportTest extends FeatureTestCase } } - public function test_page_keeps_the_latest_balance_imported_within_five_days(): void + public function test_page_defaults_to_the_latest_balance_and_supports_a_date_filter(): void { Carbon::setTestNow('2026-10-20 10:00:00'); @@ -473,12 +474,24 @@ class WithdrawalManagementImportExportTest extends FeatureTestCase 'page' => 'withdrawal-management', 'platform' => '天猫', ])); + $historyResponse = $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'fund-management', + 'page' => 'withdrawal-management', + 'platform' => '天猫', + 'balance_date' => '2026-10-14', + ])); $response->assertOk() ->assertSeeText('10月20日店铺余额') ->assertSeeText('12,345.67') ->assertDontSeeText('99,999.99') ->assertSee('余额日期:2026-10-15', false); + + $historyResponse->assertOk() + ->assertSeeText('10月14日店铺余额') + ->assertSeeText('99,999.99') + ->assertDontSeeText('12,345.67') + ->assertSee('余额日期:2026-10-14', false); } finally { Carbon::setTestNow(); } diff --git a/tests/Unit/EcommerceFinance/JushuitanShopBalanceRepositoryTest.php b/tests/Unit/EcommerceFinance/JushuitanShopBalanceRepositoryTest.php index 26196ce0..3d5ea528 100644 --- a/tests/Unit/EcommerceFinance/JushuitanShopBalanceRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/JushuitanShopBalanceRepositoryTest.php @@ -298,50 +298,36 @@ class JushuitanShopBalanceRepositoryTest extends TestCase ->value('balance')); } - public function testItSelectsTheLatestBalanceDateImportedWithinFiveDays(): void + public function testItKeepsHistoricalSnapshotsAndSelectsTheLatestBalanceDate(): void { Carbon::setTestNow('2026-10-20 10:00:00'); try { - $this->insertBalanceSnapshot('2026-10-14', '2026-10-15 23:59:59', '窗口外旧店'); - $this->insertBalanceSnapshot('2026-10-15', '2026-10-16 18:31:00', '窗口内主店'); - $this->insertBalanceSnapshot('2026-10-13', '2026-10-20 09:00:00', '窗口内补录店'); + $this->insertBalanceSnapshot('2026-09-01', '2026-09-02 10:00:00', '历史店'); + $this->insertBalanceSnapshot('2026-10-18', '2026-10-19 10:00:00', '最新店'); + $this->insertBalanceSnapshot('2026-10-25', '2026-10-25 10:00:00', '未来店'); $repository = app(JushuitanShopBalanceRepository::class); - $this->assertSame('2026-10-15', $repository->latestDateInRetention()); - $this->assertSame(1, $repository->countForDate('2026-10-15')); - $this->assertSame(0, $repository->countForDate('2026-10-14')); + $this->assertSame('2026-10-18', $repository->latestBalanceDate()); + $this->assertSame('2026-09-01', $repository->earliestBalanceDate()); + $this->assertSame(1, $repository->countForDate('2026-09-01')); + $matches = $repository->matchesForStores([ + (object) ['id' => 70, 'store_platform' => '天猫', 'shop_name' => '历史店', 'erp_store_name' => '历史店'], + ], '2026-09-01'); + $this->assertSame('matched', $matches[70]['status']); + $this->assertSame(100.0, $matches[70]['balance']); } finally { Carbon::setTestNow(); } } - public function testItPrunesSnapshotsImportedMoreThanFiveDaysAgo(): void + public function testImportingANewDateKeepsOlderSnapshots(): void { Carbon::setTestNow('2026-10-20 10:00:00'); try { - $this->insertBalanceSnapshot('2026-10-14', '2026-10-15 23:59:59', '应清理旧店'); - $this->insertBalanceSnapshot('2026-10-15', '2026-10-16 00:00:00', '边界保留店'); - - $repository = app(JushuitanShopBalanceRepository::class); - - $this->assertSame(1, $repository->pruneOutsideRetention()); - $this->assertSame(0, $repository->countForDate('2026-10-14')); - $this->assertSame(1, $repository->countForDate('2026-10-15')); - } finally { - Carbon::setTestNow(); - } - } - - public function testImportPrunesSnapshotsOlderThanTheRetentionWindow(): void - { - Carbon::setTestNow('2026-10-20 10:00:00'); - - try { - $this->insertBalanceSnapshot('2026-10-14', '2026-10-15 12:00:00', '旧导入店', 'old-scope'); - $this->insertBalanceSnapshot('2026-10-15', '2026-10-16 12:00:00', '近期导入店', 'recent-scope'); + $this->insertBalanceSnapshot('2026-09-01', '2026-09-02 12:00:00', '旧导入店', 'daily'); $path = $this->jsonFile([ 'isSuccess' => true, @@ -356,15 +342,13 @@ class JushuitanShopBalanceRepositoryTest extends TestCase $result = app(JushuitanShopBalanceImporter::class)->import($path, '2026-10-19', 'daily'); - $this->assertSame(1, $result['pruned']); - $this->assertFalse(DB::connection('business_mirror') + $this->assertSame(1, $result['total']); + $this->assertArrayNotHasKey('pruned', $result); + $this->assertTrue(DB::connection('business_mirror') ->table(JushuitanShopBalanceRepository::TABLE) ->where('store_name', '旧导入店') ->exists()); - $this->assertTrue(DB::connection('business_mirror') - ->table(JushuitanShopBalanceRepository::TABLE) - ->where('store_name', '近期导入店') - ->exists()); + $this->assertSame(1, app(JushuitanShopBalanceRepository::class)->countForDate('2026-09-01')); } finally { Carbon::setTestNow(); }