CaiWuXiTong/app/Business/WithdrawalManagementReport.php
92451 0b69f30714 feat: 补录财务账单店铺余额并标记来源
聚水潭缺失店铺按财务账单补录;页面和导出标记数据来源。

PR #552
Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
2026-10-25 17:06:19 +08:00

385 lines
16 KiB
PHP

<?php
namespace App\Business;
use App\Models\Business\StoreReceivingAccount;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
class WithdrawalManagementReport
{
private const SORT_FIELDS = [
'platform',
'erp_store_name',
'store_id',
'store_entity',
'withdrawal_path',
'withdrawal_account',
'bank_card',
'balance_two_days_ago',
'balance_yesterday',
'balance',
'balance_alert',
'reserve_standard',
'withdrawal_amount',
'compliance',
];
public function __construct(
private readonly JushuitanShopBalanceRepository $shopBalances,
) {
}
/**
* @return array{
* 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,data_source:?string}>,
* balancesByDate:array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>>,
* balanceDays:list<array{key:string,date:string,label:string}>,
* alerts:array<int,array{balance:float,start_date:string,end_date:string,message:string}>,
* reserveStandards:array<int,int|null>,withdrawalAmounts:array<int,float|null>,compliance:array<int,string|null>,
* summary:array{source:int,matched:int,finance:int,ambiguous:int,unmatched:int,not_ready:int},
* balanceColumnLabel:string,balanceDate:string,latestBalanceDate:?string,balanceDates:list<string>,sort:array{field:string,direction:string}
* }
*/
public function results(Request $request): array
{
$filters = $this->filters($request);
$sort = $this->sorting($request);
$platforms = $this->options('store_platform');
$paths = $this->options('receiving_method');
$storeEntities = $this->options('store_entity_name');
$query = StoreReceivingAccount::query();
if ($filters['platform'] !== '') {
$query->where('store_platform', $filters['platform']);
}
if ($filters['withdrawalPath'] !== '') {
$query->where('receiving_method', $filters['withdrawalPath']);
}
if ($filters['storeEntity'] !== '') {
$query->where('store_entity_name', $filters['storeEntity']);
}
if ($filters['keyword'] !== '') {
$query->where('store_name', 'like', '%' . addcslashes($filters['keyword'], '%_\\') . '%');
}
$today = now()->startOfDay();
// 余额快照按日期长期保留:默认显示最新余额日期,也可指定历史日期回看。
$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();
$historyDates = WithdrawalBalanceAlertSynchronizer::datesEndingAt($balanceDate);
$balancesByDate = $this->shopBalances->matchesForStoresOnDates($rows, $historyDates);
$balances = array_map(
static fn (array $dates): array => $dates[$balanceDate] ?? [
'status' => 'not_ready',
'balance' => null,
'source_store_name' => null,
'source_platform' => null,
],
$balancesByDate,
);
$balanceCounts = collect($balances)->countBy(
static fn (array $match): string => $match['status'],
);
$financeCount = collect($balances)->filter(
static fn (array $match): bool => ($match['data_source'] ?? null)
=== JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE,
)->count();
if ($filters['balanceStatus'] !== '') {
$rows = $rows->filter(
static fn (StoreReceivingAccount $row): bool => ($balances[(int) $row->id]['status'] ?? 'not_ready') === $filters['balanceStatus'],
);
}
$reserveStandards = $rows->mapWithKeys(
static fn (StoreReceivingAccount $row): array => [
(int) $row->id => WithdrawalReserveStandard::amountFor(
$row->store_platform,
$row->erp_store_name,
),
],
)->all();
$balanceDays = collect($historyDates)->map(
static fn (string $date, int $index): array => [
'key' => $index === 0 ? 'balance_two_days_ago' : ($index === 1 ? 'balance_yesterday' : 'balance'),
'date' => $date,
'label' => CarbonImmutable::parse($date)->format('n月j日') . '店铺余额',
],
)->values()->all();
$alerts = WithdrawalBalanceAlertSynchronizer::detect(
$rows,
$balancesByDate,
$reserveStandards,
$historyDates,
);
$withdrawalAmounts = $rows->mapWithKeys(
fn (StoreReceivingAccount $row): array => [
(int) $row->id => $this->withdrawalAmountFor($row, $balanceDate),
],
)->all();
$compliance = $rows->mapWithKeys(
fn (StoreReceivingAccount $row): array => [
(int) $row->id => self::complianceFor(
$balances[(int) $row->id] ?? ['status' => 'not_ready', 'balance' => null],
$reserveStandards[(int) $row->id] ?? null,
$withdrawalAmounts[(int) $row->id] ?? null,
),
],
)->all();
$rows = $sort['field'] === ''
? $this->sortByBalanceStatus($rows, $balances)
: $this->sortRows(
$rows,
$sort,
$balancesByDate,
$balanceDays,
$alerts,
$reserveStandards,
$withdrawalAmounts,
$compliance,
);
return [
'filters' => $filters,
'platforms' => $platforms,
'paths' => $paths,
'storeEntities' => $storeEntities,
'rows' => $rows,
'balances' => $balances,
'balancesByDate' => $balancesByDate,
'balanceDays' => $balanceDays,
'alerts' => $alerts,
'reserveStandards' => $reserveStandards,
'withdrawalAmounts' => $withdrawalAmounts,
'compliance' => $compliance,
'summary' => [
'source' => $this->shopBalances->countForDate($balanceDate),
'matched' => (int) $balanceCounts->get('matched', 0),
'finance' => $financeCount,
'ambiguous' => (int) $balanceCounts->get('ambiguous', 0),
'unmatched' => (int) $balanceCounts->get('unmatched', 0),
'not_ready' => (int) $balanceCounts->get('not_ready', 0),
],
'balanceColumnLabel' => $labelDate->format('n月j日') . '店铺余额',
'balanceDate' => $balanceDate,
'latestBalanceDate' => $latestBalanceDate,
'balanceDates' => $this->shopBalances->availableDates($today),
'sort' => $sort,
];
}
/** @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')),
'withdrawalPath' => $clean($request->query('withdrawal_path')),
'storeEntity' => $clean($request->query('store_entity'), 191),
'keyword' => $clean($request->query('keyword'), 191),
'balanceStatus' => $balanceStatus,
'balanceDate' => $balanceDate,
];
}
/** @return list<string> */
private function options(string $column): array
{
return StoreReceivingAccount::query()
->whereNotNull($column)
->where($column, '<>', '')
->distinct()
->orderBy($column)
->pluck($column)
->filter()
->values()
->all();
}
/** @return array{field:string,direction:string} */
private function sorting(Request $request): array
{
$field = trim((string) $request->query('sort'));
$direction = strtolower(trim((string) $request->query('direction')));
return [
'field' => in_array($field, self::SORT_FIELDS, true) ? $field : '',
'direction' => in_array($direction, ['asc', 'desc'], true) ? $direction : 'asc',
];
}
/**
* @param Collection<int,StoreReceivingAccount> $rows
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}> $balances
* @return Collection<int,StoreReceivingAccount>
*/
private function sortByBalanceStatus(Collection $rows, array $balances): Collection
{
$statusOrder = ['matched' => 0, 'ambiguous' => 1, 'unmatched' => 2, 'not_ready' => 3];
return $rows->sort(
static function (StoreReceivingAccount $left, StoreReceivingAccount $right) use ($balances, $statusOrder): int {
$leftStatus = $balances[(int) $left->id]['status'] ?? 'not_ready';
$rightStatus = $balances[(int) $right->id]['status'] ?? 'not_ready';
$statusComparison = ($statusOrder[$leftStatus] ?? 99) <=> ($statusOrder[$rightStatus] ?? 99);
return $statusComparison !== 0
? $statusComparison
: ((int) $right->id <=> (int) $left->id);
},
)->values();
}
/**
* @param Collection<int,StoreReceivingAccount> $rows
* @param array{field:string,direction:string} $sort
* @param array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>> $balancesByDate
* @param list<array{key:string,date:string,label:string}> $balanceDays
* @param array<int,array{balance:float,start_date:string,end_date:string,message:string}> $alerts
* @param array<int,int|null> $reserveStandards
* @param array<int,float|null> $withdrawalAmounts
* @param array<int,string|null> $compliance
* @return Collection<int,StoreReceivingAccount>
*/
private function sortRows(
Collection $rows,
array $sort,
array $balancesByDate,
array $balanceDays,
array $alerts,
array $reserveStandards,
array $withdrawalAmounts,
array $compliance,
): Collection {
return $rows->sort(function (StoreReceivingAccount $left, StoreReceivingAccount $right) use (
$sort,
$balancesByDate,
$balanceDays,
$alerts,
$reserveStandards,
$withdrawalAmounts,
$compliance,
): int {
$leftValue = $this->sortValue($left, $sort['field'], $balancesByDate, $balanceDays, $alerts, $reserveStandards, $withdrawalAmounts, $compliance);
$rightValue = $this->sortValue($right, $sort['field'], $balancesByDate, $balanceDays, $alerts, $reserveStandards, $withdrawalAmounts, $compliance);
$comparison = $this->compareSortValues($leftValue, $rightValue, $sort['direction']);
return $comparison !== 0
? $comparison
: ((int) $right->id <=> (int) $left->id);
})->values();
}
/**
* @param array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>> $balancesByDate
* @param list<array{key:string,date:string,label:string}> $balanceDays
* @param array<int,array{balance:float,start_date:string,end_date:string,message:string}> $alerts
* @param array<int,int|null> $reserveStandards
* @param array<int,float|null> $withdrawalAmounts
* @param array<int,string|null> $compliance
*/
private function sortValue(
StoreReceivingAccount $row,
string $field,
array $balancesByDate,
array $balanceDays,
array $alerts,
array $reserveStandards,
array $withdrawalAmounts,
array $compliance,
): mixed {
$balanceFor = static function (string $dayKey) use ($row, $balancesByDate, $balanceDays): ?float {
$date = null;
foreach ($balanceDays as $day) {
if ($day['key'] === $dayKey) {
$date = $day['date'];
break;
}
}
$match = $date === null
? null
: ($balancesByDate[(int) $row->id][$date] ?? null);
return ($match['status'] ?? null) === 'matched' ? (float) $match['balance'] : null;
};
return match ($field) {
'platform' => $row->store_platform,
'erp_store_name' => $row->erp_store_name,
'store_id' => $row->store_id,
'store_entity' => $row->store_entity_name,
'withdrawal_path' => $row->withdrawal_path,
'withdrawal_account' => $row->withdrawal_account_name,
'bank_card' => $row->bank_card_last_four,
'balance_two_days_ago' => $balanceFor('balance_two_days_ago'),
'balance_yesterday' => $balanceFor('balance_yesterday'),
'balance' => $balanceFor('balance'),
'balance_alert' => isset($alerts[(int) $row->id]) ? 1 : 0,
'reserve_standard' => $reserveStandards[(int) $row->id] ?? null,
'withdrawal_amount' => $withdrawalAmounts[(int) $row->id] ?? null,
'compliance' => $compliance[(int) $row->id] ?? null,
default => null,
};
}
private function withdrawalAmountFor(StoreReceivingAccount $row, string $balanceDate): ?float
{
if ($row->withdrawal_balance_date !== $balanceDate || $row->withdrawal_amount === null) {
return null;
}
return is_numeric($row->withdrawal_amount) ? (float) $row->withdrawal_amount : null;
}
/** @param array{status:string,balance:?float} $balance */
public static function complianceFor(array $balance, ?int $reserve, ?float $withdrawalAmount): ?string
{
if (($balance['status'] ?? '') !== 'matched' || $balance['balance'] === null || $reserve === null || $withdrawalAmount === null) {
return null;
}
$remainingCents = (int) round((float) $balance['balance'] * 100)
- (int) round($withdrawalAmount * 100);
return $remainingCents <= $reserve * 100 ? 'yes' : 'no';
}
private function compareSortValues(mixed $left, mixed $right, string $direction): int
{
$leftEmpty = $left === null || $left === '';
$rightEmpty = $right === null || $right === '';
if ($leftEmpty || $rightEmpty) {
return $leftEmpty === $rightEmpty ? 0 : ($leftEmpty ? 1 : -1);
}
$comparison = is_numeric($left) && is_numeric($right)
? ((float) $left <=> (float) $right)
: strnatcasecmp((string) $left, (string) $right);
return $direction === 'desc' ? -$comparison : $comparison;
}
}