connection = $mirror->connection(); } public function usesBusinessMirror(): bool { try { return $this->mirror->ready($this->dataset()); } catch (Throwable $exception) { report($exception); return false; } } /** @return string[] */ public function storeOptions(): array { $this->ensureReady(); return $this->connection->table($this->dataset()->targetTable()) ->whereNotNull('shop_name') ->where('shop_name', '<>', '') ->distinct() ->orderBy('shop_name') ->pluck('shop_name') ->map(static fn (mixed $value): string => (string) $value) ->all(); } /** @return array{rows:array>,total:int} */ public function paginateMonthlyBalances( ?string $storeName = null, ?string $month = null, int $page = 1, int $pageSize = 25, ): array { $this->ensureReady(); $month = $this->normalizeMonth($month); $page = max($page, 1); $pageSize = min(max($pageSize, 1), 5000); $driver = $this->connection->getDriverName(); $monthStart = $driver === 'sqlite' ? "date(src.show_date, 'start of month')" : "DATE_FORMAT(src.show_date, '%Y-%m-01')"; $currentMonth = CarbonImmutable::now()->startOfMonth()->toDateString(); $entryTime = 'COALESCE(src.settle_datetime, src.show_date)'; $ranked = $this->connection->table($this->dataset()->targetTable() . ' AS src') ->whereNotNull('src.shop_name') ->where('src.shop_name', '<>', '') ->whereNotNull('src.show_date') ->where('src.show_date', '<', $currentMonth) ->when($storeName !== null && $storeName !== '', fn (Builder $query) => $query->where('src.shop_name', $storeName)) ->selectRaw("src.shop_name, src.show_date AS entry_date, src.balance_amount AS entry_balance, $monthStart AS month_start") ->selectRaw("CASE WHEN src.in_out_type = 'IN' OR src.in_out_type_name = '入账' THEN src.amount ELSE 0 END AS income_value") ->selectRaw("CASE WHEN src.in_out_type = 'OUT' OR src.in_out_type_name = '出账' THEN src.amount ELSE 0 END AS expense_value") ->selectRaw("LAG(src.balance_amount) OVER (PARTITION BY src.shop_name ORDER BY src.show_date, $entryTime, src.id) AS previous_balance") ->selectRaw("LAG(src.show_date) OVER (PARTITION BY src.shop_name ORDER BY src.show_date, $entryTime, src.id) AS previous_date") ->selectRaw("ROW_NUMBER() OVER (PARTITION BY src.shop_name, $monthStart ORDER BY src.show_date, $entryTime, src.id) AS month_first_rank") ->selectRaw("ROW_NUMBER() OVER (PARTITION BY src.shop_name, $monthStart ORDER BY src.show_date DESC, $entryTime DESC, src.id DESC) AS month_last_rank"); $groups = $this->connection->query()->fromSub($ranked, 'ranked') ->when($month !== null, fn (Builder $query) => $query->where('month_start', $month . '-01')) ->selectRaw('shop_name, month_start, COALESCE(SUM(income_value), 0) AS income_total, COALESCE(SUM(expense_value), 0) AS expense_total') ->selectRaw('MAX(CASE WHEN month_first_rank = 1 THEN previous_balance END) AS opening_balance') ->selectRaw('MAX(CASE WHEN month_first_rank = 1 THEN previous_date END) AS opening_source_date') ->selectRaw('MAX(CASE WHEN month_last_rank = 1 THEN entry_balance END) AS system_closing_balance') ->selectRaw('MAX(CASE WHEN month_last_rank = 1 THEN entry_date END) AS closing_source_date') ->groupBy('shop_name') ->groupBy('month_start'); $total = $this->connection->query()->fromSub(clone $groups, 'monthly_count')->count(); if ($total === 0) { return ['rows' => [], 'total' => 0]; } $rows = $this->connection->query()->fromSub($groups, 'monthly_balances') ->orderByDesc('month_start') ->orderBy('shop_name') ->forPage($page, $pageSize) ->get() ->map(fn (object $row): array => $this->normalizeRow($row)) ->all(); return ['rows' => $rows, 'total' => $total]; } /** @return array */ private function normalizeRow(object $row): array { $monthStart = CarbonImmutable::parse((string) $row->month_start)->startOfMonth(); $expectedOpeningDate = $monthStart->subMonth()->endOfMonth()->toDateString(); $expectedClosingDate = $monthStart->endOfMonth()->toDateString(); $openingSourceDate = $row->opening_source_date === null ? null : CarbonImmutable::parse((string) $row->opening_source_date)->toDateString(); $closingSourceDate = $row->closing_source_date === null ? null : CarbonImmutable::parse((string) $row->closing_source_date)->toDateString(); $openingBalance = $openingSourceDate === $expectedOpeningDate && $row->opening_balance !== null ? round((float) $row->opening_balance, 2) : null; $income = round((float) $row->income_total, 2); $expense = round((float) $row->expense_total, 2); $systemClosing = $closingSourceDate !== $expectedClosingDate || $row->system_closing_balance === null ? null : round((float) $row->system_closing_balance, 2); $closing = $openingBalance === null ? null : round($openingBalance + $income - $expense, 2); $difference = $closing === null || $systemClosing === null ? null : round($systemClosing - $closing, 2); $verified = $difference !== null && abs($difference) < 0.005; if ($openingBalance === null && $systemClosing === null) { $reason = '缺少期初余额和系统期末余额'; } elseif ($openingBalance === null) { $reason = '缺少上月期末余额'; } elseif ($systemClosing === null) { $reason = '缺少本月系统期末余额'; } elseif ($verified) { $reason = ''; } else { $reason = sprintf('系统期末余额与计算期末余额相差 %.2f 元', abs($difference)); } return [ 'date' => $expectedClosingDate, 'store_name' => (string) $row->shop_name, 'opening_balance' => $openingBalance, 'income_total' => $income, 'expense_total' => $expense, 'closing_balance' => $closing, 'system_closing_balance' => $systemClosing, 'balance_difference' => $difference, 'difference_reason' => $reason, 'verification_status' => $verified ? '已核对' : '待核对', 'notes' => '', ]; } private function normalizeMonth(?string $month): ?string { return $month !== null && preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : null; } private function ensureReady(): void { if (! $this->usesBusinessMirror()) { throw new RuntimeException('京东账户余额共享业务镜像尚未完成同步。'); } } private function dataset(): BusinessMirrorDataset { return $this->datasets->get(self::DATASET_KEY); } }