CaiWuXiTong/modules/EcommerceFinance/Services/PeriodBalanceReconciliationService.php
qiuxiang 2d5da83780 fix: preserve reconciliation financial summary
Restore the existing financial overview totals while keeping lazy trend loading.
2026-11-11 20:21:00 +08:00

676 lines
28 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}
}