Restore the existing financial overview totals while keeping lazy trend loading.
676 lines
28 KiB
PHP
676 lines
28 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Services;
|
||
|
||
use Carbon\CarbonImmutable;
|
||
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
|
||
use RuntimeException;
|
||
|
||
class PeriodBalanceReconciliationService
|
||
{
|
||
private const TOLERANCE_YUAN = 200.0;
|
||
|
||
private const TREND_MAX_POINTS = 5;
|
||
|
||
/** @var array<string,array{source_scope:string,source_label:string}> */
|
||
private const JUSHUITAN_SOURCES = [
|
||
'taobao-alipay' => [
|
||
'source_scope' => 'taobao',
|
||
'source_label' => '聚水潭 · 店铺余额表',
|
||
],
|
||
'pinduoduo' => [
|
||
'source_scope' => 'pinduoduo',
|
||
'source_label' => '聚水潭 · 平台资金账户余额',
|
||
],
|
||
'douyin' => [
|
||
'source_scope' => 'douyin',
|
||
'source_label' => '聚水潭 · 头条放心购平台资金账户余额',
|
||
],
|
||
];
|
||
|
||
public function __construct(
|
||
private readonly StoreReceivingBalanceOverviewService $overview,
|
||
private readonly JushuitanShopBalanceRepository $jushuitanBalances,
|
||
) {}
|
||
|
||
/** @return array<string,mixed> */
|
||
public function build(string $periodStart, ?string $periodEnd = null): array
|
||
{
|
||
[$start, $end, $month] = $this->period($periodStart, $periodEnd);
|
||
$financial = $this->overview->overview($start, $end);
|
||
$financialStoreData = $this->overview->storeBalancesForPlatforms(
|
||
array_column(StoreReceivingBalanceOverviewService::PLATFORMS, 'slug'),
|
||
$start,
|
||
$end,
|
||
);
|
||
$lastJushuitan = $this->jushuitanBalances->latestComparisonSnapshots(
|
||
array_column(self::JUSHUITAN_SOURCES, 'source_scope'),
|
||
$end,
|
||
);
|
||
$jushuitanRows = $this->aggregateJushuitanRows($lastJushuitan['rows']);
|
||
$rows = $this->mergeRows(
|
||
(array) ($financialStoreData['rows'] ?? []),
|
||
$jushuitanRows,
|
||
$lastJushuitan['dates'],
|
||
$end,
|
||
);
|
||
$platforms = $this->platformRows($rows);
|
||
$sources = $this->sourceRows($lastJushuitan['dates'], $end, $financialStoreData['errors']);
|
||
$dualSource = $this->dualSourceSummary($rows, $platforms);
|
||
$anomalies = array_values(array_filter(
|
||
$rows,
|
||
static fn (array $row): bool => in_array($row['status'], ['abnormal', 'stale'], true),
|
||
));
|
||
|
||
return [
|
||
'month' => $month,
|
||
'period' => ['start' => $start, 'end' => $end],
|
||
'tolerance_yuan' => self::TOLERANCE_YUAN,
|
||
'financial' => $financial,
|
||
'dual_source' => $dualSource,
|
||
'sources' => $sources,
|
||
'source_errors' => $financialStoreData['errors'],
|
||
'platforms' => $platforms,
|
||
'stores' => $rows,
|
||
'summary' => [
|
||
'store_count' => count(array_filter($rows, static fn (array $row): bool => $row['financial_available'])),
|
||
'connected_store_count' => count(array_filter($rows, static fn (array $row): bool => $row['connected'])),
|
||
'anomaly_count' => count(array_filter($rows, static fn (array $row): bool => $row['status'] === 'abnormal')),
|
||
'stale_count' => count(array_filter($rows, static fn (array $row): bool => $row['status'] === 'stale')),
|
||
'missing_jushuitan_count' => count(array_filter($rows, static fn (array $row): bool => $row['status'] === 'jst_missing')),
|
||
'unconnected_platform_count' => count(array_filter($platforms, static fn (array $row): bool => ! $row['connected'])),
|
||
'top_anomalies' => array_slice($anomalies, 0, 3),
|
||
],
|
||
'trend' => [
|
||
'points' => [],
|
||
'by_platform' => [],
|
||
'by_store' => [],
|
||
'dates_note' => '趋势按需加载。',
|
||
],
|
||
'available_dates' => [
|
||
'start' => $this->jushuitanBalances->earliestBalanceDate(),
|
||
'end' => $this->jushuitanBalances->latestBalanceDate(),
|
||
],
|
||
'generated_at' => now()->toIso8601String(),
|
||
];
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
public function buildTrend(string $periodStart, ?string $periodEnd = null): array
|
||
{
|
||
[$start, $end] = $this->period($periodStart, $periodEnd);
|
||
$currentFinancialRows = $this->overview->storeBalancesForPlatforms(
|
||
array_keys(self::JUSHUITAN_SOURCES),
|
||
$start,
|
||
$end,
|
||
)['rows'];
|
||
|
||
return $this->trend($start, $end, $currentFinancialRows);
|
||
}
|
||
|
||
/**
|
||
* @param list<object> $rows
|
||
* @return array<string,array{scope:string,normalized:string,store_name:string,balance:float}>
|
||
*/
|
||
private function aggregateJushuitanRows(array $rows): array
|
||
{
|
||
$aggregated = [];
|
||
foreach ($rows as $row) {
|
||
$scope = trim((string) $row->source_scope);
|
||
$normalized = trim((string) $row->normalized_store_name);
|
||
if ($scope === '' || $normalized === '') {
|
||
continue;
|
||
}
|
||
$key = $scope . '|' . $normalized;
|
||
$aggregated[$key] ??= [
|
||
'scope' => $scope,
|
||
'normalized' => $normalized,
|
||
'store_name' => trim((string) $row->store_name),
|
||
'balance' => 0.0,
|
||
];
|
||
$aggregated[$key]['balance'] = round(
|
||
$aggregated[$key]['balance'] + (float) $row->balance,
|
||
2,
|
||
);
|
||
}
|
||
|
||
return $aggregated;
|
||
}
|
||
|
||
/**
|
||
* @param list<array<string,mixed>> $financialRows
|
||
* @param array<string,array{scope:string,normalized:string,store_name:string,balance:float}> $jushuitanRows
|
||
* @param array<string,string> $snapshotDates
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
private function mergeRows(
|
||
array $financialRows,
|
||
array $jushuitanRows,
|
||
array $snapshotDates,
|
||
string $end,
|
||
): array {
|
||
$rows = [];
|
||
$matchedJushuitan = [];
|
||
|
||
foreach ($financialRows as $financial) {
|
||
$platformSlug = (string) ($financial['platform_slug'] ?? '');
|
||
$platformName = (string) ($financial['platform'] ?? '');
|
||
$storeName = trim((string) ($financial['store_name'] ?? ''));
|
||
$source = self::JUSHUITAN_SOURCES[$platformSlug] ?? null;
|
||
$connected = $source !== null;
|
||
$normalized = JushuitanShopBalanceRepository::normalizeStoreName($storeName);
|
||
$jstKey = $source === null ? null : $source['source_scope'] . '|' . $normalized;
|
||
$jst = $jstKey !== null ? ($jushuitanRows[$jstKey] ?? null) : null;
|
||
if ($jst !== null) {
|
||
$matchedJushuitan[$jstKey] = true;
|
||
}
|
||
|
||
$financialBalance = is_numeric($financial['amount'] ?? null)
|
||
? round((float) $financial['amount'], 2)
|
||
: null;
|
||
$jstBalance = $jst === null ? null : round((float) $jst['balance'], 2);
|
||
$difference = $financialBalance !== null && $jstBalance !== null
|
||
? round($financialBalance - $jstBalance, 2)
|
||
: null;
|
||
$snapshotDate = $source === null ? null : ($snapshotDates[$source['source_scope']] ?? null);
|
||
$status = $this->rowStatus(
|
||
$connected,
|
||
$financialBalance,
|
||
$jstBalance,
|
||
$difference,
|
||
$snapshotDate,
|
||
$end,
|
||
);
|
||
|
||
$rows[] = [
|
||
'key' => $platformSlug . '|' . $normalized,
|
||
'platform_slug' => $platformSlug,
|
||
'platform' => $platformName,
|
||
'store_name' => $storeName,
|
||
'connected' => $connected,
|
||
'financial_available' => $financialBalance !== null,
|
||
'financial_balance' => $financialBalance,
|
||
'jushuitan_balance' => $jstBalance,
|
||
'difference' => $difference,
|
||
'status' => $status,
|
||
'snapshot_date' => $snapshotDate,
|
||
'source' => $source,
|
||
'reasons' => $this->reasonsForStatus($status, $difference, $snapshotDate, $end),
|
||
];
|
||
}
|
||
|
||
foreach ($jushuitanRows as $key => $jst) {
|
||
if (isset($matchedJushuitan[$key])) {
|
||
continue;
|
||
}
|
||
$platformSlug = $this->platformSlugForScope($jst['scope']);
|
||
if ($platformSlug === null) {
|
||
continue;
|
||
}
|
||
$platformName = $this->platformNameForSlug($platformSlug);
|
||
$source = self::JUSHUITAN_SOURCES[$platformSlug];
|
||
$snapshotDate = $snapshotDates[$source['source_scope']] ?? null;
|
||
$rows[] = [
|
||
'key' => $platformSlug . '|' . $jst['normalized'],
|
||
'platform_slug' => $platformSlug,
|
||
'platform' => $platformName,
|
||
'store_name' => $jst['store_name'],
|
||
'connected' => true,
|
||
'financial_available' => false,
|
||
'financial_balance' => null,
|
||
'jushuitan_balance' => round((float) $jst['balance'], 2),
|
||
'difference' => null,
|
||
'status' => 'financial_missing',
|
||
'snapshot_date' => $snapshotDate,
|
||
'source' => $source,
|
||
'reasons' => $this->reasonsForStatus('financial_missing', null, $snapshotDate, $end),
|
||
];
|
||
}
|
||
|
||
usort($rows, static function (array $left, array $right): int {
|
||
$leftRank = match ($left['status']) {
|
||
'abnormal', 'stale' => 0,
|
||
'jst_missing', 'financial_missing' => 1,
|
||
'normal' => 2,
|
||
default => 3,
|
||
};
|
||
$rightRank = match ($right['status']) {
|
||
'abnormal', 'stale' => 0,
|
||
'jst_missing', 'financial_missing' => 1,
|
||
'normal' => 2,
|
||
default => 3,
|
||
};
|
||
|
||
return $leftRank <=> $rightRank
|
||
?: abs((float) ($right['difference'] ?? 0)) <=> abs((float) ($left['difference'] ?? 0))
|
||
?: (float) ($right['financial_balance'] ?? 0) <=> (float) ($left['financial_balance'] ?? 0)
|
||
?: strcmp($left['platform'], $right['platform'])
|
||
?: strcmp($left['store_name'], $right['store_name']);
|
||
});
|
||
|
||
return $rows;
|
||
}
|
||
|
||
private function rowStatus(
|
||
bool $connected,
|
||
?float $financialBalance,
|
||
?float $jstBalance,
|
||
?float $difference,
|
||
?string $snapshotDate,
|
||
string $end,
|
||
): string {
|
||
if (! $connected) return 'unavailable';
|
||
if ($financialBalance === null) return 'financial_missing';
|
||
if ($jstBalance === null) return 'jst_missing';
|
||
if ($snapshotDate !== $end) return 'stale';
|
||
return abs((float) $difference) > self::TOLERANCE_YUAN ? 'abnormal' : 'normal';
|
||
}
|
||
|
||
/**
|
||
* @return list<array{kind:string,title:string,reason:string,action:string}>
|
||
*/
|
||
private function reasonsForStatus(
|
||
string $status,
|
||
?float $difference,
|
||
?string $snapshotDate,
|
||
string $end,
|
||
): array {
|
||
if ($status === 'normal' || $status === 'unavailable') {
|
||
return [];
|
||
}
|
||
if ($status === 'jst_missing') {
|
||
return [[
|
||
'kind' => '聚水潭无匹配店铺',
|
||
'title' => '聚水潭暂未匹配到该店铺',
|
||
'reason' => '当前快照没有与财务系统店铺匹配的聚水潭余额记录,可能尚未导入或店铺名称需要映射。',
|
||
'action' => '先核对聚水潭导出范围和店铺名称,再补导对应来源的余额快照。',
|
||
]];
|
||
}
|
||
if ($status === 'financial_missing') {
|
||
return [[
|
||
'kind' => '财务系统无余额',
|
||
'title' => '财务系统暂未形成该店铺期末余额',
|
||
'reason' => '聚水潭已有余额,但财务系统的期初或账单数据不足以计算期末余额。',
|
||
'action' => '核查该店铺的期初余额、账单覆盖日期及平台账单是否完整。',
|
||
]];
|
||
}
|
||
if ($status === 'stale') {
|
||
return [[
|
||
'kind' => '取数时间点差',
|
||
'title' => '两边余额取数日期不一致',
|
||
'reason' => '聚水潭快照日期为 ' . ($snapshotDate ?: '未知') . ',财务系统截止到 ' . $end . ',差异可能由日期错位造成。',
|
||
'action' => '先补导截至同一日期的聚水潭余额,再按相同日期重新核对。',
|
||
]];
|
||
}
|
||
|
||
$amount = $difference === null ? '' : number_format(abs($difference) / 10000, 2) . ' 万元';
|
||
return [[
|
||
'kind' => '双源余额差异',
|
||
'title' => '财务系统与聚水潭余额待核查',
|
||
'reason' => '当前双源差异为 ' . ($difference > 0 ? '+' : '−') . $amount . ',可能涉及在途货款、费用未同步、跨期账单、退款冲销或受限资金口径。',
|
||
'action' => '按差异方向逐笔核对当日平台扣费、退款、提现及在途结算,确认后登记差异原因。',
|
||
]];
|
||
}
|
||
|
||
/** @param list<array<string,mixed>> $rows */
|
||
private function platformRows(array $rows): array
|
||
{
|
||
return array_map(function (array $definition) use ($rows): array {
|
||
$platformRows = array_values(array_filter(
|
||
$rows,
|
||
static fn (array $row): bool => $row['platform_slug'] === $definition['slug'],
|
||
));
|
||
$financialTotal = $this->sumAvailable($platformRows, 'financial_balance');
|
||
$jstTotal = $this->sumAvailable($platformRows, 'jushuitan_balance');
|
||
$connected = isset(self::JUSHUITAN_SOURCES[$definition['slug']]);
|
||
$difference = $connected && $financialTotal !== null && $jstTotal !== null
|
||
? round($financialTotal - $jstTotal, 2)
|
||
: null;
|
||
$statuses = array_column($platformRows, 'status');
|
||
|
||
return [
|
||
'slug' => $definition['slug'],
|
||
'name' => $definition['name'],
|
||
'connected' => $connected,
|
||
'financial_balance' => $financialTotal,
|
||
'jushuitan_balance' => $jstTotal,
|
||
'difference' => $difference,
|
||
'status' => ! $connected
|
||
? 'unavailable'
|
||
: (array_intersect($statuses, ['abnormal', 'stale', 'jst_missing', 'financial_missing'])
|
||
? 'pending'
|
||
: 'normal'),
|
||
'store_count' => count($platformRows),
|
||
];
|
||
}, $this->platformDefinitions());
|
||
}
|
||
|
||
/**
|
||
* @param array<string,string> $snapshotDates
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
private function sourceRows(array $snapshotDates, string $end, array $financialErrors): array
|
||
{
|
||
return array_map(static function (array $definition) use ($snapshotDates, $end, $financialErrors): array {
|
||
$source = self::JUSHUITAN_SOURCES[$definition['slug']] ?? null;
|
||
$snapshotDate = $source === null ? null : ($snapshotDates[$source['source_scope']] ?? null);
|
||
$financialError = $financialErrors[$definition['slug']] ?? null;
|
||
|
||
return [
|
||
'slug' => $definition['slug'],
|
||
'name' => $definition['name'],
|
||
'connected' => $source !== null,
|
||
'source_label' => $source['source_label'] ?? '聚水潭暂未接入',
|
||
'snapshot_date' => $snapshotDate,
|
||
'stale' => $source !== null && $snapshotDate !== null && $snapshotDate !== $end,
|
||
'financial_error' => $financialError,
|
||
'status' => $financialError !== null
|
||
? 'financial_error'
|
||
: ($source === null
|
||
? 'unavailable'
|
||
: ($snapshotDate === null ? 'missing' : ($snapshotDate === $end ? 'ready' : 'stale'))),
|
||
];
|
||
}, $this->platformDefinitions());
|
||
}
|
||
|
||
/**
|
||
* @param list<array<string,mixed>> $rows
|
||
* @param list<array<string,mixed>> $platforms
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function dualSourceSummary(array $rows, array $platforms): array
|
||
{
|
||
$matchedRows = array_values(array_filter(
|
||
$rows,
|
||
static fn (array $row): bool => $row['connected']
|
||
&& is_numeric($row['financial_balance'] ?? null)
|
||
&& is_numeric($row['jushuitan_balance'] ?? null),
|
||
));
|
||
$financialTotal = $this->sumAvailable($matchedRows, 'financial_balance');
|
||
$jushuitanTotal = $this->sumAvailable($matchedRows, 'jushuitan_balance');
|
||
$difference = $financialTotal !== null && $jushuitanTotal !== null
|
||
? round($financialTotal - $jushuitanTotal, 2)
|
||
: null;
|
||
$connectedCount = count(array_filter($rows, static fn (array $row): bool => $row['connected']));
|
||
$missingCount = count(array_filter(
|
||
$rows,
|
||
static fn (array $row): bool => in_array($row['status'], ['jst_missing', 'financial_missing'], true),
|
||
));
|
||
$anomalyCount = count(array_filter(
|
||
$rows,
|
||
static fn (array $row): bool => $row['status'] === 'abnormal',
|
||
));
|
||
$staleCount = count(array_filter(
|
||
$rows,
|
||
static fn (array $row): bool => $row['status'] === 'stale',
|
||
));
|
||
$status = $missingCount > 0
|
||
? 'incomplete'
|
||
: ($anomalyCount > 0 || $staleCount > 0 ? 'pending' : 'normal');
|
||
|
||
return [
|
||
'status' => $status,
|
||
'financial_total' => $financialTotal,
|
||
'jushuitan_total' => $jushuitanTotal,
|
||
'difference' => $difference,
|
||
'tolerance_yuan' => self::TOLERANCE_YUAN,
|
||
'connected_store_count' => $connectedCount,
|
||
'matched_store_count' => count($matchedRows),
|
||
'missing_store_count' => $missingCount,
|
||
'anomaly_store_count' => $anomalyCount,
|
||
'stale_store_count' => $staleCount,
|
||
'connected_platforms' => array_values(array_column(array_filter(
|
||
$platforms,
|
||
static fn (array $row): bool => $row['connected'],
|
||
), 'name')),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 取实际存在的聚水潭快照日期,计算同范围财务余额,用于双源趋势。
|
||
*
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function trend(string $start, string $end, array $currentFinancialRows): array
|
||
{
|
||
$sourceScopes = array_column(self::JUSHUITAN_SOURCES, 'source_scope');
|
||
$historyStart = CarbonImmutable::parse($start)->subMonthNoOverflow()->format('Y-m-d');
|
||
$snapshotRows = $this->jushuitanBalances->comparisonSnapshotsBetween(
|
||
$sourceScopes,
|
||
$historyStart,
|
||
$end,
|
||
);
|
||
$snapshotRowsByScopeDate = [];
|
||
$snapshotStoreByScopeDate = [];
|
||
$snapshotDates = [];
|
||
foreach ($snapshotRows as $snapshot) {
|
||
$date = substr((string) $snapshot->balance_date, 0, 10);
|
||
$scope = (string) $snapshot->source_scope;
|
||
$snapshotDates[$date] = true;
|
||
$snapshotRowsByScopeDate[$scope][$date] = round(
|
||
(float) ($snapshotRowsByScopeDate[$scope][$date] ?? 0) + (float) $snapshot->balance,
|
||
2,
|
||
);
|
||
$normalized = (string) $snapshot->normalized_store_name;
|
||
$snapshotStoreByScopeDate[$scope][$date][$normalized] = round(
|
||
(float) ($snapshotStoreByScopeDate[$scope][$date][$normalized] ?? 0)
|
||
+ (float) $snapshot->balance,
|
||
2,
|
||
);
|
||
}
|
||
|
||
$dates = array_keys(array_filter(
|
||
$snapshotDates,
|
||
static fn (bool $unused, string $date): bool => $date >= $start && $date <= $end,
|
||
ARRAY_FILTER_USE_BOTH,
|
||
));
|
||
if ($dates === []) {
|
||
$latest = $this->jushuitanBalances->latestComparisonSnapshots($sourceScopes, $end);
|
||
$dates = array_values($latest['dates']);
|
||
}
|
||
$dates[] = $end;
|
||
$dates = array_values(array_unique(array_filter($dates, static fn (string $date): bool => $date <= $end)));
|
||
sort($dates);
|
||
if (count($dates) > self::TREND_MAX_POINTS) {
|
||
$sampled = [];
|
||
$lastIndex = count($dates) - 1;
|
||
for ($index = 0; $index < self::TREND_MAX_POINTS; $index++) {
|
||
$sampled[] = $dates[(int) round(
|
||
$index * $lastIndex / (self::TREND_MAX_POINTS - 1),
|
||
)];
|
||
}
|
||
$dates = array_values(array_unique($sampled));
|
||
}
|
||
$platformHistory = [];
|
||
$storeHistory = [];
|
||
$points = [];
|
||
foreach ($dates as $date) {
|
||
$financialRows = $date === $end
|
||
? $currentFinancialRows
|
||
: $this->overview->storeBalancesForPlatforms(
|
||
array_keys(self::JUSHUITAN_SOURCES),
|
||
$date,
|
||
$date,
|
||
)['rows'];
|
||
$financialByPlatform = [];
|
||
$incompleteFinancialPlatforms = [];
|
||
$financialByStore = [];
|
||
foreach ($financialRows as $row) {
|
||
$slug = (string) $row['platform_slug'];
|
||
$normalized = JushuitanShopBalanceRepository::normalizeStoreName((string) $row['store_name']);
|
||
$amount = is_numeric($row['amount'] ?? null) ? (float) $row['amount'] : null;
|
||
if ($amount === null) {
|
||
$incompleteFinancialPlatforms[$slug] = true;
|
||
} else {
|
||
$financialByPlatform[$slug] = round(
|
||
(float) ($financialByPlatform[$slug] ?? 0) + $amount,
|
||
2,
|
||
);
|
||
}
|
||
$financialByStore[$slug . '|' . $normalized] = [
|
||
'platform_slug' => $slug,
|
||
'normalized' => $normalized,
|
||
'store_name' => (string) $row['store_name'],
|
||
'finance' => $amount,
|
||
];
|
||
}
|
||
foreach (array_keys($incompleteFinancialPlatforms) as $slug) {
|
||
$financialByPlatform[$slug] = null;
|
||
}
|
||
|
||
$jushuitanByPlatform = [];
|
||
$jushuitanByStore = [];
|
||
foreach (self::JUSHUITAN_SOURCES as $platformSlug => $source) {
|
||
$scopeDates = array_keys($snapshotRowsByScopeDate[$source['source_scope']] ?? []);
|
||
$eligible = array_values(array_filter($scopeDates, static fn (string $snapshotDate): bool => $snapshotDate <= $date));
|
||
rsort($eligible);
|
||
if ($eligible === []) {
|
||
continue;
|
||
}
|
||
$snapshotDate = $eligible[0];
|
||
$jushuitanByPlatform[$platformSlug] = round(
|
||
(float) ($snapshotRowsByScopeDate[$source['source_scope']][$snapshotDate] ?? 0),
|
||
2,
|
||
);
|
||
foreach (($snapshotStoreByScopeDate[$source['source_scope']][$snapshotDate] ?? []) as $normalized => $balance) {
|
||
$jushuitanByStore[$platformSlug . '|' . $normalized] = [
|
||
'platform_slug' => $platformSlug,
|
||
'normalized' => $normalized,
|
||
'store_name' => '',
|
||
'jushuitan' => (float) $balance,
|
||
'snapshot_date' => $snapshotDate,
|
||
];
|
||
}
|
||
}
|
||
|
||
$financialTotal = $this->sumAvailable(
|
||
array_map(static fn (string $slug): array => ['financial_balance' => $financialByPlatform[$slug] ?? null], array_keys($jushuitanByPlatform)),
|
||
'financial_balance',
|
||
);
|
||
$jushuitanTotal = $this->sumAvailable(
|
||
array_map(static fn (float $balance): array => ['jushuitan_balance' => $balance], array_values($jushuitanByPlatform)),
|
||
'jushuitan_balance',
|
||
);
|
||
$difference = $financialTotal !== null && $jushuitanTotal !== null
|
||
? round($financialTotal - $jushuitanTotal, 2)
|
||
: null;
|
||
$points[] = [
|
||
'date' => $date,
|
||
'financial_balance' => $financialTotal,
|
||
'jushuitan_balance' => $jushuitanTotal,
|
||
'difference' => $difference,
|
||
'status' => $difference === null ? 'unavailable' : (abs($difference) > self::TOLERANCE_YUAN ? 'abnormal' : 'normal'),
|
||
];
|
||
|
||
foreach (array_keys(self::JUSHUITAN_SOURCES) as $slug) {
|
||
$platformHistory[$slug][] = [
|
||
'date' => $date,
|
||
'financial_balance' => $financialByPlatform[$slug] ?? null,
|
||
'jushuitan_balance' => $jushuitanByPlatform[$slug] ?? null,
|
||
];
|
||
}
|
||
foreach ($financialByStore as $key => $row) {
|
||
$jst = $jushuitanByStore[$key] ?? null;
|
||
$storeHistory[$key][] = [
|
||
'date' => $date,
|
||
'financial_balance' => $row['finance'],
|
||
'jushuitan_balance' => $jst['jushuitan'] ?? null,
|
||
'snapshot_date' => $jst['snapshot_date'] ?? null,
|
||
];
|
||
}
|
||
foreach ($jushuitanByStore as $key => $row) {
|
||
if (isset($financialByStore[$key])) {
|
||
continue;
|
||
}
|
||
$storeHistory[$key][] = [
|
||
'date' => $date,
|
||
'financial_balance' => null,
|
||
'jushuitan_balance' => $row['jushuitan'],
|
||
'snapshot_date' => $row['snapshot_date'],
|
||
];
|
||
}
|
||
}
|
||
|
||
return [
|
||
'points' => $points,
|
||
'by_platform' => $platformHistory,
|
||
'by_store' => $storeHistory,
|
||
'dates_note' => '趋势仅按现有聚水潭快照日期绘制,不推算缺失日期。',
|
||
];
|
||
}
|
||
|
||
/** @return array{0:string,1:string,2:string} */
|
||
private function period(string $periodStart, ?string $periodEnd): array
|
||
{
|
||
if ($periodEnd === null && preg_match('/^\d{4}-\d{2}$/', $periodStart) === 1) {
|
||
$month = CarbonImmutable::createFromFormat('!Y-m', $periodStart, 'Asia/Shanghai');
|
||
if ($month instanceof CarbonImmutable && $month->format('Y-m') === $periodStart) {
|
||
return [
|
||
$month->startOfMonth()->format('Y-m-d'),
|
||
$month->endOfMonth()->format('Y-m-d'),
|
||
$periodStart,
|
||
];
|
||
}
|
||
}
|
||
|
||
try {
|
||
$start = CarbonImmutable::createFromFormat('!Y-m-d', $periodStart, 'Asia/Shanghai');
|
||
$end = CarbonImmutable::createFromFormat('!Y-m-d', (string) $periodEnd, 'Asia/Shanghai');
|
||
} catch (\Throwable) {
|
||
throw new RuntimeException('invalid_period_balance_reconciliation_period');
|
||
}
|
||
if (! $start instanceof CarbonImmutable
|
||
|| ! $end instanceof CarbonImmutable
|
||
|| $start->format('Y-m-d') !== $periodStart
|
||
|| $end->format('Y-m-d') !== (string) $periodEnd
|
||
|| $end->lt($start)
|
||
) {
|
||
throw new RuntimeException('invalid_period_balance_reconciliation_period');
|
||
}
|
||
|
||
return [$start->format('Y-m-d'), $end->format('Y-m-d'), $start->format('Y-m')];
|
||
}
|
||
|
||
/** @param list<array<string,mixed>> $rows */
|
||
private function sumAvailable(array $rows, string $key): ?float
|
||
{
|
||
$amounts = array_filter(
|
||
array_map(static fn (array $row): mixed => $row[$key] ?? null, $rows),
|
||
static fn (mixed $value): bool => is_numeric($value),
|
||
);
|
||
if ($amounts === []) {
|
||
return null;
|
||
}
|
||
|
||
return round((float) array_sum($amounts), 2);
|
||
}
|
||
|
||
/** @return list<array{slug:string,name:string}> */
|
||
private function platformDefinitions(): array
|
||
{
|
||
return StoreReceivingBalanceOverviewService::PLATFORMS;
|
||
}
|
||
|
||
private function platformSlugForScope(string $scope): ?string
|
||
{
|
||
foreach (self::JUSHUITAN_SOURCES as $slug => $source) {
|
||
if ($source['source_scope'] === $scope) {
|
||
return $slug;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private function platformNameForSlug(string $slug): string
|
||
{
|
||
foreach ($this->platformDefinitions() as $definition) {
|
||
if ($definition['slug'] === $slug) {
|
||
return $definition['name'];
|
||
}
|
||
}
|
||
|
||
return $slug;
|
||
}
|
||
}
|