connection = $database->connection( (string) config('upstream_mirrors.connection', 'business_mirror') ); } public function usesBusinessMirror(): bool { return $this->hasTable(self::TABLE); } /** @return string[] */ public function storeOptions(): array { if (! $this->usesBusinessMirror()) { return []; } $stores = $this->connection->table(self::TABLE) ->whereNotNull('store_name') ->where('store_name', '<>', '') ->distinct() ->pluck('store_name') ->map(static fn (mixed $value): string => (string) $value) ->all(); if ($this->billMirrorReady()) { $billStores = $this->connection->table(self::BILL_TABLE) ->where('tradeDay', '>=', self::FIRST_CALCULATED_MONTH . '-01') ->whereNotNull('shopName') ->where('shopName', '<>', '') ->distinct() ->pluck('shopName') ->map(static fn (mixed $value): string => (string) $value) ->all(); $stores = array_merge($stores, $billStores); } $stores = array_values(array_unique($stores)); sort($stores, SORT_NATURAL); return $stores; } /** @return array{rows:array>,total:int} */ public function paginateMonthlyBalances( ?string $storeName = null, ?string $month = null, int $page = 1, int $pageSize = 25, ): array { if (! $this->usesBusinessMirror()) { return ['rows' => [], 'total' => 0]; } $month = $this->normalizeMonth($month); $rows = []; if ($month === null || $month < self::FIRST_CALCULATED_MONTH) { $rows = $this->historicalRows($storeName, $month); } if (($month === null || $month >= self::FIRST_CALCULATED_MONTH) && $this->billMirrorReady()) { $rows = array_merge($rows, $this->calculatedRows($storeName, $month)); } usort($rows, static function (array $left, array $right): int { $dateOrder = strcmp((string) $right['date'], (string) $left['date']); return $dateOrder !== 0 ? $dateOrder : strnatcmp((string) $left['store_name'], (string) $right['store_name']); }); $page = max($page, 1); $pageSize = min(max($pageSize, 1), 5000); return [ 'rows' => array_slice($rows, ($page - 1) * $pageSize, $pageSize), 'total' => count($rows), ]; } /** @return array> */ private function historicalRows(?string $storeName, ?string $month): array { $query = $this->connection->table(self::TABLE) ->where('balance_date', '<=', self::CUTOFF_DATE); $this->applyHistoricalFilters($query, $storeName, $month); return $query->get([ 'balance_date', 'store_name', 'opening_balance', 'income_total', 'expense_total', 'closing_balance', 'system_closing_balance', 'balance_difference', 'difference_reason', 'verification_status', 'notes', ])->map(fn (object $row): array => $this->mapHistoricalRow($row))->all(); } /** @return array> */ private function calculatedRows(?string $storeName, ?string $month): array { $currentMonth = CarbonImmutable::now()->startOfMonth(); if ($month !== null && $month >= $currentMonth->format('Y-m')) { return []; } $query = $this->connection->table(self::BILL_TABLE) ->where('tradeDay', '>=', self::FIRST_CALCULATED_MONTH . '-01') ->where('tradeDay', '<', $currentMonth->toDateString()) ->whereNotNull('shopName') ->where('shopName', '<>', ''); if ($storeName !== null && $storeName !== '') { $query->where('shopName', $storeName); } if ($month !== null) { $query->where('tradeDay', '<', CarbonImmutable::createFromFormat('!Y-m', $month) ->addMonth()->toDateString()); } $monthExpression = $this->monthExpression('tradeDay'); $aggregates = $query ->selectRaw($monthExpression . ' AS balance_month, shopName') ->selectRaw('COALESCE(SUM(tradeAMTIn), 0) AS income_total') ->selectRaw('COALESCE(SUM(tradeAMTOut), 0) AS expense_total') ->groupByRaw($monthExpression . ', shopName') ->orderBy('shopName') ->orderBy('balance_month') ->get(); if ($aggregates->isEmpty()) { return []; } $stores = $aggregates->pluck('shopName') ->map(static fn (mixed $value): string => (string) $value) ->unique() ->values() ->all(); $balances = $this->connection->table(self::TABLE) ->where('balance_date', self::CUTOFF_DATE) ->whereIn('store_name', $stores) ->pluck('closing_balance', 'store_name') ->map(static fn (mixed $value): ?float => $value === null ? null : (float) $value) ->all(); $rows = []; foreach ($aggregates as $aggregate) { $store = (string) $aggregate->shopName; $balanceMonth = (string) $aggregate->balance_month; $openingBalance = array_key_exists($store, $balances) ? $balances[$store] : null; $income = round((float) $aggregate->income_total, 2); $expense = round((float) $aggregate->expense_total, 2); $closingBalance = $openingBalance === null ? null : round($openingBalance + $income - $expense, 2); $balances[$store] = $closingBalance; if ($month !== null && $balanceMonth !== $month) { continue; } $difference = $closingBalance === null ? null : round(-$closingBalance, 2); $isVerified = $difference !== null && abs($difference) < 0.005; $reason = $openingBalance === null ? '缺少上月期末余额' : ($isVerified ? '' : sprintf('系统期末余额与计算期末余额相差 %.2f 元', abs($difference))); $rows[] = [ 'date' => CarbonImmutable::createFromFormat('!Y-m', $balanceMonth)->endOfMonth()->toDateString(), 'store_name' => $store, 'opening_balance' => $openingBalance, 'income_total' => $income, 'expense_total' => $expense, 'closing_balance' => $closingBalance, 'system_closing_balance' => 0.0, 'balance_difference' => $difference, 'difference_reason' => $reason, 'verification_status' => $isVerified ? '已核对' : '待核对', 'notes' => '', ]; } return $rows; } /** @return array */ private function mapHistoricalRow(object $row): array { return [ 'date' => (string) $row->balance_date, 'store_name' => (string) $row->store_name, 'opening_balance' => $row->opening_balance === null ? null : (float) $row->opening_balance, 'income_total' => $row->income_total === null ? null : (float) $row->income_total, 'expense_total' => $row->expense_total === null ? null : (float) $row->expense_total, 'closing_balance' => $row->closing_balance === null ? null : (float) $row->closing_balance, 'system_closing_balance' => $row->system_closing_balance === null ? null : (float) $row->system_closing_balance, 'balance_difference' => $row->balance_difference === null ? null : (float) $row->balance_difference, 'difference_reason' => (string) ($row->difference_reason ?? ''), 'verification_status' => (string) ($row->verification_status ?? ''), 'notes' => (string) ($row->notes ?? ''), ]; } private function applyHistoricalFilters(Builder $query, ?string $storeName, ?string $month): void { if ($storeName !== null && $storeName !== '') { $query->where('store_name', $storeName); } if ($month !== null) { $nextMonth = CarbonImmutable::createFromFormat('!Y-m', $month)->addMonth()->toDateString(); $query->where('balance_date', '>=', $month . '-01') ->where('balance_date', '<', $nextMonth); } } private function normalizeMonth(?string $month): ?string { return $month !== null && preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : null; } private function monthExpression(string $column): string { return $this->connection->getDriverName() === 'sqlite' ? "strftime('%Y-%m', {$column})" : "DATE_FORMAT({$column}, '%Y-%m')"; } private function hasTable(string $table): bool { return Schema::connection($this->connection->getName())->hasTable($table); } private function billMirrorReady(): bool { return $this->hasTable(self::BILL_TABLE) && $this->billMirror->ready(MarketingAccountBillMirrorRepository::ALIBABA); } }