fix: 提现管理店铺余额保留最近5天 #349

Merged
yutu merged 1 commits from feature/92451-withdrawal-balance-retention into main 2026-10-16 19:00:50 +08:00
7 changed files with 242 additions and 17 deletions

View File

@ -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),

View File

@ -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<int,StoreReceivingAccount>,
@ -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);

View File

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

View File

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

View File

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

View File

@ -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<list<mixed>> $rows */
private function workbook(array $rows): UploadedFile
{

View File

@ -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<string,mixed> $payload */
private function jsonFile(array $payload): string
{