*/ 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 */ 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 */ 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 $rows * @return array */ 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> $financialRows * @param array $jushuitanRows * @param array $snapshotDates * @return list> */ 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 */ 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> $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 $snapshotDates * @return list> */ 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> $rows * @param list> $platforms * @return array */ 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 */ 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> $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 */ 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; } }