feat: 提现管理支持按余额日期筛选并长期保留每日余额

This commit is contained in:
92451 2026-10-18 11:52:02 +08:00
parent b49d23d090
commit da794c3ddb
10 changed files with 120 additions and 81 deletions

View File

@ -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<string>,paths:list<string>,storeEntities:list<string>,rows:Collection<int,StoreReceivingAccount>,
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>,
* reserveStandards:array<int,int|null>,withdrawalAmounts:array<int,float|null>,compliance:array<int,string|null>,
* 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,
];
}

View File

@ -436,7 +436,7 @@ class WithdrawalManagementSpreadsheet
{
$today = now()->startOfDay();
return $this->shopBalances->latestDateInRetention($today)
return $this->shopBalances->latestBalanceDate($today)
?? $today->copy()->subDay()->toDateString();
}

View File

@ -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;

View File

@ -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'],
];
}

View File

@ -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<object> $stores
* @return array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>
*/
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);
}
}

View File

@ -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,
];
}

View File

@ -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 @@
<input type="hidden" name="sort" value="{{ $withdrawalSort['field'] }}">
<input type="hidden" name="direction" value="{{ $withdrawalSort['direction'] }}">
@endif
<div class="biz-filter-field">
<label for="withdrawal-balance-date">余额日期</label>
<input
id="withdrawal-balance-date"
class="biz-filter-input"
type="date"
name="balance_date"
value="{{ $withdrawalFilters['balanceDate'] }}"
@if ($withdrawalEarliestBalanceDate) min="{{ $withdrawalEarliestBalanceDate }}" @endif
@if ($withdrawalLatestBalanceDate) max="{{ $withdrawalLatestBalanceDate }}" @endif
title="不选时显示最新余额"
>
</div>
<div class="biz-filter-field">
<label for="withdrawal-platform">店铺平台</label>
<select id="withdrawal-platform" class="biz-filter-select" name="platform">
@ -106,7 +119,7 @@
</select>
</div>
<button type="submit" class="biz-btn biz-btn-primary">筛选</button>
@if (request()->hasAny(['platform', 'withdrawal_path', 'store_entity', 'keyword', 'balance_status']))
@if (request()->hasAny(['platform', 'withdrawal_path', 'store_entity', 'keyword', 'balance_status', 'balance_date']))
<a class="biz-btn biz-btn-ghost" href="{{ request()->url() }}">重置</a>
@endif
</form>

View File

@ -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"',

View File

@ -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();
}

View File

@ -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();
}