fix: 提现管理店铺余额保留最近5天 #349
@ -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),
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user