diff --git a/app/Business/WithdrawalManagementReport.php b/app/Business/WithdrawalManagementReport.php index e9d1ae76..687d88ce 100644 --- a/app/Business/WithdrawalManagementReport.php +++ b/app/Business/WithdrawalManagementReport.php @@ -61,9 +61,12 @@ class WithdrawalManagementReport } $today = now()->startOfDay(); - $balanceDate = $today->copy()->subDay()->toDateString(); + // 余额快照按导入日期保留 5 个自然日:优先显示最近 5 天内导入的最新余额日期; + // 当天尚未导入新数据时,继续沿用最近一次可用余额,避免页面直接变成待同步。 + $balanceDate = $this->shopBalances->latestDateInRetention($today) + ?? $today->copy()->subDay()->toDateString(); $rows = $query->orderByDesc('id')->get(); - $balances = $this->shopBalances->matchesForStores($rows, $balanceDate); + $balances = $this->shopBalances->matchesForStores($rows, $balanceDate, $today); $balanceCounts = collect($balances)->countBy( static fn (array $match): string => $match['status'], ); @@ -111,7 +114,7 @@ class WithdrawalManagementReport 'withdrawalAmounts' => $withdrawalAmounts, 'compliance' => $compliance, 'summary' => [ - 'source' => $this->shopBalances->countForDate($balanceDate), + 'source' => $this->shopBalances->countForDate($balanceDate, $today), 'matched' => (int) $balanceCounts->get('matched', 0), 'ambiguous' => (int) $balanceCounts->get('ambiguous', 0), 'unmatched' => (int) $balanceCounts->get('unmatched', 0), diff --git a/app/Business/WithdrawalManagementSpreadsheet.php b/app/Business/WithdrawalManagementSpreadsheet.php index 9aa2bc5f..75699b5d 100644 --- a/app/Business/WithdrawalManagementSpreadsheet.php +++ b/app/Business/WithdrawalManagementSpreadsheet.php @@ -13,6 +13,7 @@ use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Style\Alignment; use PhpOffice\PhpSpreadsheet\Style\Fill; use PhpOffice\PhpSpreadsheet\Writer\Xlsx; +use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository; use Symfony\Component\HttpFoundation\StreamedResponse; use Throwable; @@ -30,6 +31,11 @@ class WithdrawalManagementSpreadsheet private const CLEAR_VALUE = '[清空]'; + public function __construct( + private readonly JushuitanShopBalanceRepository $shopBalances, + ) { + } + /** * @param array{ * rows:\Illuminate\Support\Collection, @@ -63,7 +69,7 @@ class WithdrawalManagementSpreadsheet ['1. 店铺平台、ERP店铺名称用于定位店铺;有平台店铺ID时优先按平台和ID匹配。'], ['2. 店铺主体、提现路径、提现账户名称、银行卡号后四位和提现金额为可更新字段。'], ['3. 可更新字段留空表示保持原值,填写“' . self::CLEAR_VALUE . '”表示清空原值。'], - ['4. 提现金额须为非负数,最多两位小数,只用于导入当天对应的余额数据日期;上传后先预览再确认。'], + ['4. 提现金额须为非负数,最多两位小数,只用于当前页面显示的余额数据日期(最近 5 天内最新可用日期);上传后先预览再确认。'], ], null, 'A1'); $instructions->getStyle('A1')->getFont()->setBold(true)->setSize(14); $instructions->getColumnDimension('A')->setWidth(88); @@ -97,7 +103,7 @@ class WithdrawalManagementSpreadsheet } [$headerIndex, $headers] = $this->headers($matrix); $indexes = array_flip($headers); - $balanceDate = now()->subDay()->toDateString(); + $balanceDate = $this->currentBalanceDate(); $accounts = StoreReceivingAccount::withoutGlobalScopes() ->where('company_id', $companyId) ->whereNull('deleted_at') @@ -221,6 +227,7 @@ class WithdrawalManagementSpreadsheet { return DB::transaction(function () use ($plan, $companyId): int { $updated = 0; + $currentBalanceDate = null; foreach ($plan as $item) { /** @var StoreReceivingAccount|null $account */ @@ -238,11 +245,13 @@ class WithdrawalManagementSpreadsheet } if (isset($item['changes']['withdrawal_balance_date']) - && $item['changes']['withdrawal_balance_date'] !== null - && $item['changes']['withdrawal_balance_date'] !== now()->subDay()->toDateString()) { - throw ValidationException::withMessages([ - 'import' => '余额日期已滚动,请重新上传并预览提现金额。', - ]); + && $item['changes']['withdrawal_balance_date'] !== null) { + $currentBalanceDate ??= $this->currentBalanceDate(); + if ($item['changes']['withdrawal_balance_date'] !== $currentBalanceDate) { + throw ValidationException::withMessages([ + 'import' => '余额日期已滚动,请重新上传并预览提现金额。', + ]); + } } foreach ($item['changes'] as $field => $value) { @@ -423,6 +432,14 @@ class WithdrawalManagementSpreadsheet return $changes; } + private function currentBalanceDate(): string + { + $today = now()->startOfDay(); + + return $this->shopBalances->latestDateInRetention($today) + ?? $today->copy()->subDay()->toDateString(); + } + private function key(string $platform, string $identity): string { return trim($platform) . "\0" . trim($identity); diff --git a/app/Console/Commands/ImportJushuitanShopBalances.php b/app/Console/Commands/ImportJushuitanShopBalances.php index f8292e56..1c8e0920 100644 --- a/app/Console/Commands/ImportJushuitanShopBalances.php +++ b/app/Console/Commands/ImportJushuitanShopBalances.php @@ -32,11 +32,12 @@ class ImportJushuitanShopBalances extends Command } $this->info(sprintf( - '%s:%s 共 %d 条,替换原有 %d 条。', + '%s:%s 共 %d 条,替换原有 %d 条,清理 5 天前 %d 条。', $result['dry_run'] ? '校验完成' : '导入完成', $result['balance_date'], $result['total'], $result['replaced'], + $result['pruned'], )); return self::SUCCESS; diff --git a/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php b/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php index eec7cb07..3fc45116 100644 --- a/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php +++ b/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php @@ -2,6 +2,7 @@ namespace Modules\EcommerceFinance\Repositories; +use Carbon\CarbonInterface; use Illuminate\Database\ConnectionInterface; use Illuminate\Database\DatabaseManager; use Illuminate\Support\Facades\Schema; @@ -10,6 +11,12 @@ 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) @@ -23,7 +30,7 @@ class JushuitanShopBalanceRepository * @param iterable $stores * @return array */ - public function matchesForStores(iterable $stores, string $balanceDate): array + public function matchesForStores(iterable $stores, string $balanceDate, ?CarbonInterface $today = null): array { $stores = is_array($stores) ? $stores : iterator_to_array($stores); $result = []; @@ -38,6 +45,7 @@ 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', @@ -97,7 +105,7 @@ class JushuitanShopBalanceRepository return $result; } - public function countForDate(string $balanceDate): int + public function countForDate(string $balanceDate, ?CarbonInterface $today = null): int { if (! $this->hasTable()) { return 0; @@ -105,9 +113,42 @@ class JushuitanShopBalanceRepository return $this->connection->table(self::TABLE) ->where('balance_date', $balanceDate) + ->where('imported_at', '>=', $this->retentionImportedFrom($today)) ->count(); } + /** + * 取最近 5 个自然日内导入过的最新余额日期;没有可用导入时返回 null。 + */ + public function latestDateInRetention(?CarbonInterface $today = null): ?string + { + if (! $this->hasTable()) { + return null; + } + + $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'); + + return $latest === null ? null : substr((string) $latest, 0, 10); + } + + /** + * 清理导入时间早于最近 5 个自然日的快照,返回删除行数。 + */ + public function pruneOutsideRetention(?CarbonInterface $today = null): int + { + if (! $this->hasTable()) { + return 0; + } + + return $this->connection->table(self::TABLE) + ->where('imported_at', '<', $this->retentionImportedFrom($today)) + ->delete(); + } + public static function normalizeStoreName(string $value): string { $value = trim(str_replace(['-', '—', '–'], '-', $value)); @@ -176,4 +217,9 @@ 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 9b5dd533..05459617 100644 --- a/modules/EcommerceFinance/Services/JushuitanShopBalanceImporter.php +++ b/modules/EcommerceFinance/Services/JushuitanShopBalanceImporter.php @@ -15,14 +15,16 @@ class JushuitanShopBalanceImporter { private readonly ConnectionInterface $connection; - public function __construct(DatabaseManager $database) - { + public function __construct( + DatabaseManager $database, + private readonly JushuitanShopBalanceRepository $shopBalances, + ) { $this->connection = $database->connection( (string) config('upstream_mirrors.connection', 'business_mirror'), ); } - /** @return array{balance_date:string,total:int,replaced:int,dry_run:bool} */ + /** @return array{balance_date:string,total:int,replaced:int,pruned:int,dry_run:bool} */ public function import( string $path, ?string $balanceDate = null, @@ -85,6 +87,7 @@ class JushuitanShopBalanceImporter ->where('balance_date', $selectedDate) ->where('source_scope', $sourceScope) ->count(); + $pruned = 0; if (! $dryRun) { $now = now(); @@ -92,7 +95,7 @@ class JushuitanShopBalanceImporter $sourceSha256 = hash_file('sha256', $realPath); $this->connection->transaction(function () use ( - $rows, $selectedDate, $sourceScope, $sourceName, $sourceSha256, $now + $rows, $selectedDate, $sourceScope, $sourceName, $sourceSha256, $now, &$pruned ): void { $this->connection->table(JushuitanShopBalanceRepository::TABLE) ->where('balance_date', $selectedDate) @@ -113,6 +116,8 @@ class JushuitanShopBalanceImporter $chunk, )); } + + $pruned = $this->shopBalances->pruneOutsideRetention($now); }); } @@ -120,6 +125,7 @@ class JushuitanShopBalanceImporter 'balance_date' => $selectedDate, 'total' => count($rows), 'replaced' => $existing, + 'pruned' => $pruned, 'dry_run' => $dryRun, ]; } diff --git a/tests/Feature/Business/WithdrawalManagementImportExportTest.php b/tests/Feature/Business/WithdrawalManagementImportExportTest.php index be6f98e6..49584d8e 100644 --- a/tests/Feature/Business/WithdrawalManagementImportExportTest.php +++ b/tests/Feature/Business/WithdrawalManagementImportExportTest.php @@ -366,6 +366,7 @@ class WithdrawalManagementImportExportTest extends FeatureTestCase ]; } $mirror = Mockery::mock(JushuitanShopBalanceRepository::class); + $mirror->shouldReceive('latestDateInRetention')->andReturn('2026-09-23'); $mirror->shouldReceive('matchesForStores')->andReturn($balances); $mirror->shouldReceive('countForDate')->andReturn(6); $this->app->instance(JushuitanShopBalanceRepository::class, $mirror); @@ -451,6 +452,61 @@ class WithdrawalManagementImportExportTest extends FeatureTestCase } } + public function test_page_keeps_the_latest_balance_imported_within_five_days(): void + { + Carbon::setTestNow('2026-10-20 10:00:00'); + + try { + StoreReceivingAccount::query()->create([ + 'company_id' => $this->company->id, + 'store_platform' => '天猫', + 'erp_store_name' => '五天保留测试店', + 'shop_name' => '五天保留测试店', + 'store_entity_name' => '五天保留测试主体', + ]); + + $this->insertBalanceSnapshot('2026-10-15', '2026-10-16 18:31:00', '天猫-五天保留测试店', 12345.67); + $this->insertBalanceSnapshot('2026-10-14', '2026-10-15 18:31:00', '天猫-五天保留测试店', 99999.99); + + $response = $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'fund-management', + 'page' => 'withdrawal-management', + 'platform' => '天猫', + ])); + + $response->assertOk() + ->assertSeeText('10月20日店铺余额') + ->assertSeeText('12,345.67') + ->assertDontSeeText('99,999.99') + ->assertSee('余额日期:2026-10-15', false); + } finally { + Carbon::setTestNow(); + } + } + + private function insertBalanceSnapshot( + string $balanceDate, + string $importedAt, + string $storeName, + float $balance, + ): void { + DB::connection('business_mirror')->table(JushuitanShopBalanceRepository::TABLE)->insert([ + 'balance_date' => $balanceDate, + 'source_scope' => 'all', + 'source_platform' => '天猫', + 'store_name' => $storeName, + 'normalized_store_name' => JushuitanShopBalanceRepository::normalizeStoreName($storeName), + 'platform_account_id' => 'feature-' . $balanceDate, + 'balance' => $balance, + 'source_row_key' => hash('sha256', $balanceDate . '|' . $storeName), + 'source_file_name' => 'feature-fixture.json', + 'source_sha256' => str_repeat('d', 64), + 'imported_at' => Carbon::parse($importedAt), + 'created_at' => Carbon::parse($importedAt), + 'updated_at' => Carbon::parse($importedAt), + ]); + } + /** @param list> $rows */ private function workbook(array $rows): UploadedFile { diff --git a/tests/Unit/EcommerceFinance/JushuitanShopBalanceRepositoryTest.php b/tests/Unit/EcommerceFinance/JushuitanShopBalanceRepositoryTest.php index 7e582fc7..26196ce0 100644 --- a/tests/Unit/EcommerceFinance/JushuitanShopBalanceRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/JushuitanShopBalanceRepositoryTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\EcommerceFinance; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository; use Modules\EcommerceFinance\Services\JushuitanShopBalanceImporter; @@ -297,6 +298,101 @@ class JushuitanShopBalanceRepositoryTest extends TestCase ->value('balance')); } + public function testItSelectsTheLatestBalanceDateImportedWithinFiveDays(): 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', '窗口内补录店'); + + $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')); + } finally { + Carbon::setTestNow(); + } + } + + public function testItPrunesSnapshotsImportedMoreThanFiveDaysAgo(): 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'); + + $path = $this->jsonFile([ + 'isSuccess' => true, + 'fldHeads' => [ + ['title' => '店铺', 'fld' => 'shop_name'], + ['title' => '10-19', 'fld' => 'balance'], + ], + 'dataList' => [ + ['shop_name' => '拼多多-今日导入店', 'balance' => 123.45], + ], + ]); + + $result = app(JushuitanShopBalanceImporter::class)->import($path, '2026-10-19', 'daily'); + + $this->assertSame(1, $result['pruned']); + $this->assertFalse(DB::connection('business_mirror') + ->table(JushuitanShopBalanceRepository::TABLE) + ->where('store_name', '旧导入店') + ->exists()); + $this->assertTrue(DB::connection('business_mirror') + ->table(JushuitanShopBalanceRepository::TABLE) + ->where('store_name', '近期导入店') + ->exists()); + } finally { + Carbon::setTestNow(); + } + } + + private function insertBalanceSnapshot( + string $balanceDate, + string $importedAt, + string $storeName, + string $sourceScope = 'all', + ): void { + DB::connection('business_mirror')->table(JushuitanShopBalanceRepository::TABLE)->insert([ + 'balance_date' => $balanceDate, + 'source_scope' => $sourceScope, + 'source_platform' => '天猫', + 'store_name' => $storeName, + 'normalized_store_name' => JushuitanShopBalanceRepository::normalizeStoreName($storeName), + 'platform_account_id' => 'retention-' . $balanceDate, + 'balance' => 100, + 'source_row_key' => hash('sha256', $balanceDate . '|' . $sourceScope . '|' . $storeName), + 'source_file_name' => 'retention-fixture.json', + 'source_sha256' => str_repeat('c', 64), + 'imported_at' => Carbon::parse($importedAt), + 'created_at' => Carbon::parse($importedAt), + 'updated_at' => Carbon::parse($importedAt), + ]); + } + /** @param array $payload */ private function jsonFile(array $payload): string {