['title' => '账户总表', 'icon' => 'account_balance'], 'shops' => ['title' => '店铺台账', 'icon' => 'storefront'], 'flows' => ['title' => '每日资金流水', 'icon' => 'waterfall_chart'], 'transactions' => ['title' => '动账明细', 'icon' => 'receipt_long'], 'mapping' => ['title' => '账户映射', 'icon' => 'alt_route'], 'sources' => ['title' => '数据来源', 'icon' => 'database'], ]; private const ACCOUNT_FILTER_EMPTY = '__empty__'; public function __construct( private readonly FinanceLedgerShopDirectory $shopDirectory, private readonly JushuitanShopBalanceRepository $shopBalances, ) { } public function page(?string $tab = null): array { $tab = (string) (request('tab') ?: $tab); $tab = array_key_exists($tab, self::TABS) ? $tab : 'accounts'; $openingDate = (string) config('finance_ledger.opening_date', '2026-10-17'); $accounts = $this->accounts(); $shops = $this->shops(); $availableBreakdown = $this->availableBreakdown($shops); $flows = $this->flows(); $transactions = $this->transactions(); return [ 'view' => 'business.pages.financial-ledger.ledger', 'ledgerTab' => $tab, 'ledgerTabs' => self::TABS, 'ledgerOpeningDate' => $openingDate, 'ledgerAccounts' => $accounts['rows'], 'ledgerAccountSummary' => $accounts['summary'], 'ledgerShops' => $shops['rows'], 'ledgerShopSummary' => $shops['summary'], 'ledgerShopBalanceDate' => $shops['balance_date'] ?? null, 'ledgerShopBalanceDates' => $shops['balance_dates'] ?? [], 'ledgerAvailableBreakdown' => $availableBreakdown, 'ledgerFlows' => $flows['rows'], 'ledgerFlowSummary' => $flows['summary'], 'ledgerTransactions' => $transactions['rows'], 'ledgerTransactionSummary' => $transactions['summary'], 'ledgerMapping' => $this->mapping(), 'ledgerSourceSummary' => $this->sourceSummary(), 'ledgerMeta' => $this->meta(), 'ledgerAccountOptions' => $this->accountOptions(), 'ledgerManualTransactionAccountOptions' => $this->manualTransactionAccountOptions(), 'ledgerTransactionAccountHeaderOptions' => $this->transactionAccountHeaderOptions(), 'ledgerTransactionDateHeaderOptions' => $this->transactionDateHeaderOptions(), 'ledgerAccountFilterOptions' => $this->accountFilterOptions(), 'ledgerCompanyOptions' => $this->companyOptions(), 'ledgerAccountStatusOptions' => FinanceLedgerAccountStatus::OPTIONS, 'ledgerAccountPlanOptions' => $this->accountPlanOptions(), 'ledgerAccountKeepOptions' => FinanceLedgerAccountShouldKeep::OPTIONS, 'ledgerCanEditMapping' => FinanceLedgerAccess::canEditMapping(user()), 'ledgerCanEditAccountStatus' => FinanceLedgerAccess::canEditAccountStatus(user()), 'ledgerCanEditAccountType' => FinanceLedgerAccess::canEditAccountType(user()), ]; } private function accounts(): array { $query = $this->db()->table('fin_accounts as a'); $keyword = trim((string) request('accounts_q', '')); if ($keyword !== '') { $query->where(function (Builder $builder) use ($keyword): void { foreach (['a.company_name', 'a.account_short', 'a.account_no', 'a.owner'] as $column) { $builder->orWhere($column, 'like', '%' . $keyword . '%'); } }); } $accountShort = trim((string) request('accounts_short', '')); if ($accountShort !== '') { $query->where('a.account_short', 'like', '%' . $accountShort . '%'); } $status = trim((string) request('accounts_status', '')); if ($status !== '' && array_key_exists($status, FinanceLedgerAccountStatus::OPTIONS)) { if ($status === FinanceLedgerAccountStatus::NORMAL) { $nonNormalStatuses = array_values(array_diff( array_keys(FinanceLedgerAccountStatus::OPTIONS), [FinanceLedgerAccountStatus::NORMAL], )); $query->whereNotIn( 'a.account_short', $this->db()->table('fin_account_statuses') ->select('account_short') ->whereIn('status', $nonNormalStatuses), ); } else { $query->whereIn( 'a.account_short', $this->db()->table('fin_account_statuses') ->select('account_short') ->where('status', $status), ); } } $region = trim((string) request('accounts_region', '')); if ($region !== '') { if ($region === self::ACCOUNT_FILTER_EMPTY) { $query->where(function (Builder $builder): void { $builder->whereNull('a.region')->orWhere('a.region', ''); }); } else { $query->where('a.region', $region); } } $accountType = trim((string) request('accounts_type', '')); if ($accountType !== '') { $query->where(function (Builder $effectiveType) use ($accountType): void { $effectiveType->where(function (Builder $baseType) use ($accountType): void { $baseType->whereNotIn( 'a.account_short', $this->db()->table('fin_account_type_overrides')->select('account_short'), ); if ($accountType === self::ACCOUNT_FILTER_EMPTY) { $baseType->where(function (Builder $blank): void { $blank->whereNull('a.account_type')->orWhere('a.account_type', ''); }); } else { $baseType->where('a.account_type', $accountType); } }); $overrideType = $this->db()->table('fin_account_type_overrides')->select('account_short'); if ($accountType === self::ACCOUNT_FILTER_EMPTY) { $overrideType->where(function (Builder $blank): void { $blank->whereNull('account_type')->orWhere('account_type', ''); }); } else { $overrideType->where('account_type', $accountType); } $effectiveType->orWhereIn('a.account_short', $overrideType); }); } $balanceRange = trim((string) request('accounts_balance', '')); if ($balanceRange === '0-5') { $query->where('a.current_balance', '>=', 0)->where('a.current_balance', '<', 50000); } elseif ($balanceRange === '5-20') { $query->where('a.current_balance', '>=', 50000)->where('a.current_balance', '<', 200000); } elseif ($balanceRange === '20-plus') { $query->where('a.current_balance', '>=', 200000); } $headerColumns = $this->accountHeaderColumns(); $this->applyLedgerHeaderFilter($query, 'accounts', $headerColumns); $summaryQuery = clone $query; $summary = [ 'count' => (clone $query)->count(), 'unretained' => FinanceLedgerAccountShouldKeep::applyUnretainedCondition( clone $query, 'a.account_should_keep', 'a.company_plan', )->count(), 'current_balance' => round((float) (clone $summaryQuery)->sum('a.current_balance'), 2), 'balance' => round((float) (clone $summaryQuery)->sum('a.daily_available'), 2), 'blocked' => round((float) (clone $summaryQuery)->sum('a.blocked_amount'), 2), ]; $sorted = $this->applyLedgerHeaderSort($query, 'accounts', $headerColumns); if (! $sorted) { $query->orderBy('a.company_name')->orderBy('a.account_short'); } $rows = $query ->paginate(100, ['*'], 'accounts_page') ->withQueryString(); $accountShorts = $rows->pluck('account_short')->filter()->unique()->values()->all(); $statuses = $accountShorts === [] ? collect() : $this->db()->table('fin_account_statuses') ->whereIn('account_short', $accountShorts) ->pluck('status', 'account_short'); $typeOverrides = $accountShorts === [] ? collect() : $this->db()->table('fin_account_type_overrides') ->whereIn('account_short', $accountShorts) ->pluck('account_type', 'account_short'); $rows->getCollection()->transform(static function ($row) use ($statuses, $typeOverrides) { $row->account_status = FinanceLedgerAccountStatus::normalize($statuses[$row->account_short] ?? null); if ($typeOverrides->has($row->account_short)) { $row->account_type = (string) ($typeOverrides->get($row->account_short) ?? ''); } $row->account_should_keep = FinanceLedgerAccountShouldKeep::display( $row->account_should_keep ?? null, $row->company_plan ?? null, ); return $row; }); return ['rows' => $rows, 'summary' => $summary]; } private function shops(): array { $directoryRows = $this->shopDirectory->rows(); if ($directoryRows === null) { return $this->shopsFromLedgerTable(); } $context = $this->storeLedgerBalanceContext($directoryRows); return $this->shopsFromStoreReport($context['rows'], $context); } /** * 店铺余额按日期读取专用快照。店铺收款账户账单有真实余额的平台 * 优先使用账单来源,其余平台由同步任务以聚水潭余额补足并明确标注。 * * @param list> $rows * @return array{ * rows:list>,balance_date:?string, * balance_dates:list,alert_count:int * } */ private function storeLedgerBalanceContext(array $rows): array { $latestAllowed = now()->startOfDay()->subDay(); $balanceDates = $this->shopBalances->availableDates( $latestAllowed, StoreReceivingBillBalanceSnapshotSynchronizer::SOURCE_SCOPE, ); $requestedDate = trim((string) request('shops_balance_date', '')); $balanceDate = in_array($requestedDate, $balanceDates, true) ? $requestedDate : ($balanceDates[0] ?? null); $reserveStandards = []; foreach ($rows as $index => $row) { $reserveStandards[(int) ($row['id'] ?? $index)] = WithdrawalReserveStandard::amountFor( (string) ($row['platform'] ?? ''), (string) ($row['shop_name'] ?? ''), ); $rows[$index]['shop_balance_source'] = ($row['shop_balance_date'] ?? null) ? StoreReceivingBillBalanceSnapshotSynchronizer::sourceLabel( JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN, ) : '未同步'; $rows[$index]['shop_balance_status'] = ($row['shop_balance_date'] ?? null) ? 'matched' : 'not_ready'; $rows[$index]['reserve_standard'] = $reserveStandards[(int) ($row['id'] ?? $index)]; $rows[$index]['balance_alert'] = '否'; $rows[$index]['balance_alert_message'] = null; } if ($balanceDate === null) { return [ 'rows' => $rows, 'balance_date' => null, 'balance_dates' => [], 'alert_count' => 0, ]; } $stores = array_map( static fn (array $row): object => (object) $row, $rows, ); $historyDates = WithdrawalBalanceAlertSynchronizer::datesEndingAt($balanceDate); $matches = $this->shopBalances->matchesForStoresOnDates( $stores, $historyDates, null, StoreReceivingBillBalanceSnapshotSynchronizer::SOURCE_SCOPE, ); $alerts = WithdrawalBalanceAlertSynchronizer::detect( $stores, $matches, array_map( static fn (?int $amount): ?int => $amount === null ? null : (int) $amount, $reserveStandards, ), $historyDates, ); foreach ($rows as $index => $row) { $id = (int) ($row['id'] ?? $index); $match = $matches[$id][$balanceDate] ?? [ 'status' => 'not_ready', 'balance' => null, 'data_source' => null, 'source_file_name' => null, ]; $matched = ($match['status'] ?? '') === 'matched' && ($match['balance'] ?? null) !== null; $rows[$index]['shop_balance'] = $matched ? round((float) $match['balance'], 2) : null; $rows[$index]['shop_balance_date'] = $matched ? $balanceDate : null; $rows[$index]['shop_balance_status'] = (string) ($match['status'] ?? 'not_ready'); $rows[$index]['shop_balance_source'] = $matched ? StoreReceivingBillBalanceSnapshotSynchronizer::sourceLabel( $match['data_source'] ?? null, $match['source_file_name'] ?? null, ) : '未同步'; $alert = $alerts[$id] ?? null; $rows[$index]['balance_alert'] = $alert === null ? '否' : '是'; $rows[$index]['balance_alert_message'] = $alert['message'] ?? null; } return [ 'rows' => $rows, 'balance_date' => $balanceDate, 'balance_dates' => $balanceDates, 'alert_count' => count($alerts), ]; } /** * @param list> $rows * @param array{balance_date:?string,balance_dates:list,alert_count:int}|array{} $context * @return array{ * rows:LengthAwarePaginator,summary:array{count:int,balance:float,alert_count:int}, * overall_summary:array{count:int,balance:float},balance_date:?string, * balance_dates:list,alert_count:int * } */ private function shopsFromStoreReport(array $rows, array $context = []): array { $overallSummary = [ 'count' => count($rows), 'balance' => round(array_sum(array_map( static fn (array $row): float => (float) ($row['shop_balance'] ?? 0), $rows, )), 2), ]; $keyword = trim((string) request('shops_q', '')); $platform = trim((string) request('shops_platform', '')); $company = trim((string) request('shops_company', '')); $rows = array_values(array_filter( $rows, static function (array $row) use ($keyword, $platform, $company): bool { if ($platform !== '' && trim((string) ($row['platform'] ?? '')) !== $platform) { return false; } if ($company !== '' && mb_stripos((string) ($row['company_name'] ?? ''), $company) === false) { return false; } if ($keyword === '') { return true; } foreach (['shop_name', 'platform', 'company_name'] as $field) { if (mb_stripos((string) ($row[$field] ?? ''), $keyword) !== false) { return true; } } return false; }, )); $headerColumns = $this->shopHeaderColumns(); $rows = $this->filterAndSortHeaderRows($rows, 'shops', $headerColumns); if (! isset($headerColumns[trim((string) request('shops_sort', ''))])) { usort($rows, static fn (array $left, array $right): int => [ (string) ($left['company_name'] ?? ''), (string) ($left['shop_name'] ?? ''), ] <=> [ (string) ($right['company_name'] ?? ''), (string) ($right['shop_name'] ?? ''), ]); } $summary = [ 'count' => count($rows), 'balance' => round(array_sum(array_map( static fn (array $row): float => (float) ($row['shop_balance'] ?? 0), $rows, )), 2), 'alert_count' => count(array_filter( $rows, static fn (array $row): bool => ($row['balance_alert'] ?? '') === '是', )), ]; $perPage = 100; $currentPage = Paginator::resolveCurrentPage('shops_page'); $items = array_map( static fn (array $row): object => (object) $row, array_slice($rows, ($currentPage - 1) * $perPage, $perPage), ); return [ 'rows' => (new LengthAwarePaginator( $items, count($rows), $perPage, $currentPage, [ 'path' => Paginator::resolveCurrentPath(), 'query' => request()->query(), 'pageName' => 'shops_page', ], ))->withQueryString(), 'summary' => $summary, 'overall_summary' => $overallSummary, 'balance_date' => $context['balance_date'] ?? null, 'balance_dates' => $context['balance_dates'] ?? [], 'alert_count' => $summary['alert_count'], ]; } /** @return array{rows:LengthAwarePaginator,summary:array{count:int,balance:float},overall_summary:array{count:int,balance:float}} */ private function shopsFromLedgerTable(): array { $baseQuery = $this->db()->table('fin_shops'); $overallSummary = [ 'count' => (clone $baseQuery)->count(), 'balance' => round((float) (clone $baseQuery)->sum('shop_balance'), 2), ]; $query = clone $baseQuery; foreach (['shops_q' => ['shop_name', 'platform', 'company_name'], 'shops_platform' => ['platform'], 'shops_company' => ['company_name']] as $param => $columns) { $value = trim((string) request($param, '')); if ($value === '') { continue; } $query->where(function (Builder $builder) use ($columns, $value): void { foreach ($columns as $column) { $builder->orWhere($column, 'like', '%' . $value . '%'); } }); } $headerColumns = $this->shopHeaderColumns(); $this->applyLedgerHeaderFilter($query, 'shops', $headerColumns); $summary = [ 'count' => (clone $query)->count(), 'balance' => round((float) (clone $query)->sum('shop_balance'), 2), 'alert_count' => 0, ]; $sorted = $this->applyLedgerHeaderSort($query, 'shops', $headerColumns); if (! $sorted) { $query->orderBy('company_name')->orderBy('shop_name'); } $rows = $query ->paginate(100, ['*'], 'shops_page')->withQueryString(); $rows->getCollection()->transform(static function ($row) { $row->shop_balance_source = ($row->shop_balance_date ?? null) ? StoreReceivingBillBalanceSnapshotSynchronizer::sourceLabel( JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN, ) : '未同步'; $row->shop_balance_status = ($row->shop_balance_date ?? null) ? 'matched' : 'not_ready'; $row->reserve_standard = WithdrawalReserveStandard::amountFor( (string) ($row->platform ?? ''), (string) ($row->shop_name ?? ''), ); $row->balance_alert = '否'; $row->balance_alert_message = null; return $row; }); return [ 'rows' => $rows, 'summary' => $summary, 'overall_summary' => $overallSummary, 'balance_date' => $this->latestShopBalanceDate(), 'balance_dates' => $this->shopBalances->availableDates( now()->startOfDay()->subDay(), StoreReceivingBillBalanceSnapshotSynchronizer::SOURCE_SCOPE, ), 'alert_count' => 0, ]; } /** * 综合可用金额始终按全部账户和全部店铺计算,不受账户/店铺筛选影响。 * * @return array{bank_available:float,shop_available:float,total:float,shop_balance_date:?string} */ private function availableBreakdown(array $shops): array { $bankAvailable = round((float) $this->db()->table('fin_accounts')->sum('daily_available'), 2); $shopAvailable = round((float) ($shops['overall_summary']['balance'] ?? 0), 2); return [ 'bank_available' => $bankAvailable, 'shop_available' => $shopAvailable, 'total' => round($bankAvailable + $shopAvailable, 2), 'shop_balance_date' => $shops['balance_date'] ?? $this->latestShopBalanceDate(), ]; } private function flows(): array { $query = $this->db()->table('fin_daily_flows'); foreach (['flows_date' => 'flow_date', 'flows_company' => 'company_name', 'flows_account' => 'account_short'] as $param => $column) { $value = trim((string) request($param, '')); if ($value !== '') { $query->where($column, $param === 'flows_company' || $param === 'flows_account' ? 'like' : '=', $param === 'flows_company' || $param === 'flows_account' ? '%' . $value . '%' : $value); } } $headerColumns = $this->flowHeaderColumns(); $this->applyLedgerHeaderFilter($query, 'flows', $headerColumns); $summary = [ 'count' => (clone $query)->count(), 'closing' => round((float) (clone $query)->sum('closing_balance'), 2), 'inflow' => round((float) (clone $query)->sum('inflow'), 2), 'outflow' => round((float) (clone $query)->sum('outflow'), 2), ]; $sorted = $this->applyLedgerHeaderSort($query, 'flows', $headerColumns); if (! $sorted) { $query->orderBy('flow_date')->orderBy('account_short'); } return [ 'rows' => $query ->paginate(100, ['*'], 'flows_page')->withQueryString(), 'summary' => $summary, ]; } private function transactions(): array { $query = $this->transactionQuery(); $this->applyTransactionFilters($query); $headerColumns = $this->transactionHeaderColumns(); $summary = $this->transactionSummaryMetrics($query); $sorted = $this->applyLedgerHeaderSort($query, 'transactions', $headerColumns); if (! $sorted) { $query->orderByDesc('t.txn_date')->orderByDesc('t.id'); } return [ 'rows' => $query ->paginate(100, ['*'], 'txns_page')->withQueryString(), 'summary' => $summary, ]; } public function transactionSummary(): array { $query = $this->transactionQuery(); $this->applyTransactionFilters($query); return $this->transactionSummaryMetrics($query); } private function applyTransactionFilters(Builder $query): void { $keyword = trim((string) request('txns_q', '')); if ($keyword !== '') { $query->where(function (Builder $builder) use ($keyword): void { $builder->where('t.detail', 'like', '%' . $keyword . '%') ->orWhere('t.approval_no', 'like', '%' . $keyword . '%') ->orWhere('t.account_short', 'like', '%' . $keyword . '%') ->orWhere('m.account_short', 'like', '%' . $keyword . '%'); }); } $account = trim((string) request('txns_account', '')); if ($account !== '') { $effectiveAccount = $this->transactionHeaderColumns()['effective_account_short']['sort']; $query->whereRaw($effectiveAccount . ' LIKE ?', ['%' . $account . '%']); } $from = trim((string) request('txns_from', '')); if ($from !== '') { $query->where('t.txn_date', '>=', $from); } $to = trim((string) request('txns_to', '')); if ($to !== '') { $query->where('t.txn_date', '<=', $to); } $source = trim((string) request('txns_source', '')); if ($source !== '') { $query->where('t.source', $source); } $this->applyLedgerHeaderFilter($query, 'transactions', $this->transactionHeaderColumns()); } private function transactionSummaryMetrics(Builder $query): array { return [ 'count' => (clone $query)->count(), 'income' => round((float) (clone $query)->sum('t.income'), 2), 'expense' => round((float) (clone $query)->sum('t.expense'), 2), ]; } private function transactionQuery(): Builder { $effectiveAccount = $this->transactionHeaderColumns()['effective_account_short']['sort']; return $this->db()->table('fin_transactions as t') ->leftJoin('fin_account_map as m', function ($join): void { $join->on('m.source', '=', 't.source')->on('m.source_value', '=', 't.source_value'); }) ->select([ 't.id', 't.txn_date', 't.account_short', 't.detail', 't.income', 't.expense', 't.approval_no', 't.source', 't.source_value', 't.process_type', 't.row_key', 't.synced_at', 't.created_at', 't.updated_at', ]) ->selectRaw($effectiveAccount . ' AS effective_account_short'); } private function transactionAccountHeaderOptions(): array { $effectiveAccount = $this->transactionHeaderColumns()['effective_account_short']['sort']; return $this->db()->table('fin_transactions as t') ->leftJoin('fin_account_map as m', function ($join): void { $join->on('m.source', '=', 't.source') ->on('m.source_value', '=', 't.source_value'); }) ->selectRaw($effectiveAccount . ' AS filter_value') ->distinct() ->orderBy('filter_value') ->pluck('filter_value') ->map(static fn (mixed $value): string => trim((string) $value)) ->filter() ->unique() ->mapWithKeys(static fn (string $value): array => [$value => $value]) ->all(); } private function transactionDateHeaderOptions(): array { return $this->db()->table('fin_transactions') ->select('txn_date') ->distinct() ->orderByDesc('txn_date') ->pluck('txn_date') ->map(static fn (mixed $value): string => (string) $value) ->filter() ->unique() ->mapWithKeys(static fn (string $value): array => [$value => $value]) ->all(); } private function mapping(): array { $query = $this->db()->table('fin_account_map'); $source = trim((string) request('mapping_source', '')); if ($source !== '') { $query->where('source', $source); } $keyword = trim((string) request('mapping_q', '')); if ($keyword !== '') { $query->where(function (Builder $builder) use ($keyword): void { $builder->where('source_value', 'like', '%' . $keyword . '%') ->orWhere('account_short', 'like', '%' . $keyword . '%'); }); } if (request()->boolean('mapping_unmapped')) { $query->where(function (Builder $builder): void { $builder->whereNull('account_short')->orWhere('account_short', ''); }); } $headerColumns = $this->mappingHeaderColumns(); $this->applyLedgerHeaderFilter($query, 'mapping', $headerColumns); $total = (clone $query)->count(); $mapped = (clone $query)->whereNotNull('account_short')->where('account_short', '<>', '')->count(); $sorted = $this->applyLedgerHeaderSort($query, 'mapping', $headerColumns); if (! $sorted) { $query->orderByDesc('txn_count')->orderBy('source_value'); } return [ 'rows' => $query ->paginate(100, ['*'], 'mapping_page')->withQueryString(), 'total' => $total, 'mapped' => $mapped, 'all_total' => $this->db()->table('fin_account_map')->count(), 'all_mapped' => $this->db()->table('fin_account_map')->whereNotNull('account_short')->where('account_short', '<>', '')->count(), 'sources' => $this->db()->table('fin_account_map')->select('source')->distinct()->orderBy('source')->pluck('source'), ]; } private function sourceSummary(): array { $rows = $this->db()->table('fin_transactions') ->selectRaw('source, process_type, COUNT(*) AS rows_count, SUM(income) AS income, SUM(expense) AS expense, MIN(txn_date) AS first_date, MAX(txn_date) AS last_date') ->groupBy('source', 'process_type') ->orderBy('source') ->orderByDesc('rows_count') ->get() ->map(static function ($row): array { $data = (array) $row; $data['date_range'] = trim((string) ($data['first_date'] ?? '') . ' ~ ' . (string) ($data['last_date'] ?? '')); return $data; }) ->all(); return $this->filterAndSortHeaderRows($rows, 'sources', $this->sourceHeaderColumns()); } private function meta(): array { return [ 'accounts' => $this->db()->table('fin_accounts')->count(), 'shops' => $this->db()->table('fin_shops')->count(), 'companies' => $this->db()->table('fin_companies')->count(), 'opening_rows' => $this->db()->table('fin_account_opening')->count(), 'opening_sum' => round((float) $this->db()->table('fin_account_opening')->sum('opening_balance'), 2), 'transactions' => $this->db()->table('fin_transactions')->count(), 'income' => round((float) $this->db()->table('fin_transactions')->sum('income'), 2), 'expense' => round((float) $this->db()->table('fin_transactions')->sum('expense'), 2), 'flows' => $this->db()->table('fin_daily_flows')->count(), 'mapping_total' => $this->db()->table('fin_account_map')->count(), 'mapping_done' => $this->db()->table('fin_account_map')->whereNotNull('account_short')->where('account_short', '<>', '')->count(), 'last_synced_at' => $this->db()->table('fin_transactions')->max('synced_at'), ]; } private function latestShopBalanceDate(): ?string { if (! Schema::connection($this->db()->getName())->hasTable(JushuitanShopBalanceRepository::TABLE)) { return null; } $latest = $this->db()->table(JushuitanShopBalanceRepository::TABLE) ->where('source_scope', StoreReceivingBillBalanceSnapshotSynchronizer::SOURCE_SCOPE) ->where('balance_date', '<=', now()->startOfDay()->subDay()->toDateString()) ->max('balance_date'); return $latest === null ? null : substr((string) $latest, 0, 10); } private function accountOptions(): array { return $this->db()->table('fin_accounts') ->select('account_short')->distinct()->orderBy('account_short')->pluck('account_short')->all(); } private function manualTransactionAccountOptions(): array { return $this->db()->table('fin_accounts') ->select(['account_short', 'company_name']) ->whereNotNull('account_short') ->where('account_short', '<>', '') ->orderBy('account_short') ->orderBy('company_name') ->orderBy('id') ->get() ->groupBy('account_short') ->map(static function ($rows, string $accountShort): array { $first = $rows->first(); $companyName = trim((string) ($first->company_name ?? '')); $accountCount = $rows->count(); return [ 'value' => $accountShort, 'label' => $accountShort . ($companyName !== '' ? ' · ' . $companyName : '') . ($accountCount > 1 ? '(' . $accountCount . ' 个账号)' : ''), ]; }) ->values() ->all(); } private function accountFilterOptions(): array { $baseTypes = $this->db()->table('fin_accounts') ->whereNotNull('account_type')->where('account_type', '<>', '') ->distinct()->orderBy('account_type')->pluck('account_type')->all(); $overrideTypes = $this->db()->table('fin_account_type_overrides') ->whereNotNull('account_type')->where('account_type', '<>', '') ->distinct()->orderBy('account_type')->pluck('account_type')->all(); $types = FinanceLedgerAccountType::options($baseTypes, $overrideTypes); return [ 'regions' => $this->db()->table('fin_accounts') ->whereNotNull('region')->where('region', '<>', '') ->distinct()->orderBy('region')->pluck('region')->all(), 'types' => $types, ]; } private function accountPlanOptions(): array { return $this->db()->table('fin_accounts') ->whereNotNull('company_plan') ->where('company_plan', '<>', '') ->distinct() ->orderBy('company_plan') ->pluck('company_plan') ->map(static fn (mixed $value): string => trim((string) $value)) ->filter() ->unique() ->values() ->all(); } private function companyOptions(): array { return $this->db()->table('fin_companies') ->whereNotNull('name') ->pluck('name') ->merge( $this->db()->table('fin_accounts') ->whereNotNull('company_name') ->distinct() ->pluck('company_name'), ) ->map(static fn (mixed $value): string => trim((string) $value)) ->filter() ->unique() ->sortBy(static fn (string $value): string => mb_strtolower($value)) ->values() ->all(); } private function applyLedgerHeaderFilter(Builder $query, string $tab, array $columns): void { $column = trim((string) request($tab . '_filter_column', '')); $value = trim((string) request($tab . '_filter_value', '')); if ($value === '' || ! isset($columns[$column])) { return; } $definition = $columns[$column]; $expression = $definition['filter'] ?? $definition['sort'] ?? null; if (! is_string($expression) || $expression === '') { return; } $type = $definition['type'] ?? 'text'; if ($type === 'number') { if (is_numeric($value)) { $query->whereRaw($expression . ' = ?', [$value]); } return; } if (in_array($type, ['enum', 'date'], true)) { $query->whereRaw($expression . ' = ?', [$value]); return; } $query->whereRaw($expression . ' LIKE ?', ['%' . $value . '%']); } private function applyLedgerHeaderSort(Builder $query, string $tab, array $columns): bool { $column = trim((string) request($tab . '_sort', '')); if (! isset($columns[$column])) { return false; } $expression = $columns[$column]['sort'] ?? null; if (! is_string($expression) || $expression === '') { return false; } $direction = strtolower((string) request($tab . '_direction', 'asc')) === 'desc' ? 'desc' : 'asc'; $query->orderByRaw($expression . ' ' . $direction); return true; } private function filterAndSortHeaderRows(array $rows, string $tab, array $columns): array { $filterColumn = trim((string) request($tab . '_filter_column', '')); $filterValue = trim((string) request($tab . '_filter_value', '')); if ($filterValue !== '' && isset($columns[$filterColumn])) { $valueColumn = $columns[$filterColumn]['value'] ?? $filterColumn; $type = $columns[$filterColumn]['type'] ?? 'text'; $rows = array_values(array_filter( $rows, static function (array $row) use ($valueColumn, $filterValue, $type): bool { $value = trim((string) ($row[$valueColumn] ?? '')); if ($type === 'number') { return is_numeric($filterValue) && is_numeric($value) && (float) $value === (float) $filterValue; } if (in_array($type, ['enum', 'date'], true)) { return $value === $filterValue; } return mb_stripos($value, $filterValue) !== false; }, )); } $sortColumn = trim((string) request($tab . '_sort', '')); if (isset($columns[$sortColumn])) { $valueColumn = $columns[$sortColumn]['value'] ?? $sortColumn; $direction = strtolower((string) request($tab . '_direction', 'asc')) === 'desc' ? -1 : 1; $type = $columns[$sortColumn]['type'] ?? 'text'; usort($rows, static function (array $left, array $right) use ($valueColumn, $direction, $type): int { $leftValue = $left[$valueColumn] ?? null; $rightValue = $right[$valueColumn] ?? null; if ($type === 'number') { return ((float) $leftValue <=> (float) $rightValue) * $direction; } return strnatcasecmp((string) $leftValue, (string) $rightValue) * $direction; }); } return $rows; } private function accountHeaderColumns(): array { $a = $this->queryAlias('a'); $statusTable = $this->tableName('fin_account_statuses'); $typeTable = $this->tableName('fin_account_type_overrides'); $statusExpression = "COALESCE((SELECT NULLIF(s.status, '') FROM {$statusTable} s WHERE s.account_short = {$a}.account_short LIMIT 1), 'normal')"; $typeExpression = "COALESCE((SELECT NULLIF(o.account_type, '') FROM {$typeTable} o WHERE o.account_short = {$a}.account_short LIMIT 1), NULLIF({$a}.account_type, ''), '')"; $keepExpression = "COALESCE(NULLIF({$a}.account_should_keep, ''), CASE WHEN COALESCE({$a}.company_plan, '') = '' THEN '' WHEN {$a}.company_plan LIKE '%保留%' THEN '是' ELSE '否' END)"; return [ 'company_name' => ['sort' => $a . '.company_name', 'type' => 'text'], 'account_short' => ['sort' => $a . '.account_short', 'type' => 'text'], 'account_type' => ['sort' => $typeExpression, 'type' => 'enum'], 'account_status' => ['sort' => $statusExpression, 'type' => 'enum'], 'region' => ['sort' => $a . '.region', 'type' => 'text'], 'account_no' => ['sort' => $a . '.account_no', 'type' => 'text'], 'bank_name' => ['sort' => $a . '.bank_name', 'type' => 'text'], 'bank_city_branch' => ['sort' => $a . '.bank_city_branch', 'type' => 'text'], 'company_plan' => ['sort' => $a . '.company_plan', 'type' => 'enum'], 'account_should_keep' => ['sort' => $keepExpression, 'type' => 'enum'], 'account_last4' => ['sort' => $a . '.account_last4', 'type' => 'text'], 'single_limit' => ['sort' => "CAST({$a}.single_limit AS DECIMAL(18, 2))", 'filter' => $a . '.single_limit', 'type' => 'number'], 'daily_limit' => ['sort' => "CAST({$a}.daily_limit AS DECIMAL(18, 2))", 'filter' => $a . '.daily_limit', 'type' => 'number'], 'monthly_limit' => ['sort' => "CAST({$a}.monthly_limit AS DECIMAL(18, 2))", 'filter' => $a . '.monthly_limit', 'type' => 'number'], 'yearly_limit' => ['sort' => "CAST({$a}.yearly_limit AS DECIMAL(18, 2))", 'filter' => $a . '.yearly_limit', 'type' => 'number'], 'current_balance' => ['sort' => $a . '.current_balance', 'type' => 'number'], 'daily_available' => ['sort' => $a . '.daily_available', 'type' => 'number'], 'blocked_amount' => ['sort' => $a . '.blocked_amount', 'type' => 'number'], 'daily_withdraw' => ['sort' => $a . '.daily_available', 'type' => 'number'], 'clearing_days' => ['sort' => $a . '.clearing_days', 'type' => 'number'], 'raise_target' => ['sort' => "CAST({$a}.raise_target AS DECIMAL(18, 2))", 'filter' => $a . '.raise_target', 'type' => 'number'], 'raise_status' => ['sort' => $a . '.raise_status', 'type' => 'text'], 'raise_actions' => ['sort' => $a . '.raise_actions', 'type' => 'text'], 'done_actions' => ['sort' => $a . '.done_actions', 'type' => 'text'], 'next_action' => ['sort' => $a . '.next_action', 'type' => 'text'], 'remark' => ['sort' => $a . '.remark', 'type' => 'text'], 'owner' => ['sort' => $a . '.owner', 'type' => 'text'], ]; } private function shopHeaderColumns(): array { return [ 'platform' => ['sort' => 'platform', 'type' => 'text'], 'shop_name' => ['sort' => 'shop_name', 'type' => 'text'], 'company_name' => ['sort' => 'company_name', 'type' => 'text'], 'account_short' => ['sort' => 'account_short', 'type' => 'text'], 'status' => ['sort' => 'status', 'type' => 'text'], 'store_manager' => ['sort' => 'shop_name', 'type' => 'text'], 'department_manager' => ['sort' => 'shop_name', 'type' => 'text'], 'payout_account' => ['sort' => 'payout_account', 'type' => 'text'], 'shop_balance_source' => ['sort' => 'shop_balance_source', 'type' => 'text'], 'shop_balance' => ['sort' => 'shop_balance', 'type' => 'number'], 'reserve_standard' => ['sort' => 'reserve_standard', 'type' => 'number'], 'balance_alert' => ['sort' => 'balance_alert', 'type' => 'enum'], 'remark' => ['sort' => 'remark', 'type' => 'text'], ]; } private function flowHeaderColumns(): array { return [ 'flow_date' => ['sort' => 'flow_date', 'type' => 'date'], 'company_name' => ['sort' => 'company_name', 'type' => 'text'], 'account_short' => ['sort' => 'account_short', 'type' => 'text'], 'opening_balance' => ['sort' => 'opening_balance', 'type' => 'number'], 'inflow' => ['sort' => 'inflow', 'type' => 'number'], 'outflow' => ['sort' => 'outflow', 'type' => 'number'], 'closing_balance' => ['sort' => 'closing_balance', 'type' => 'number'], 'remark' => ['sort' => 'remark', 'type' => 'text'], ]; } private function transactionHeaderColumns(): array { $t = $this->queryAlias('t'); $m = $this->queryAlias('m'); $effectiveAccount = "COALESCE(NULLIF({$m}.account_short, ''), NULLIF({$t}.account_short, ''), '未匹配')"; return [ 'txn_date' => ['sort' => $t . '.txn_date', 'type' => 'date'], 'effective_account_short' => ['sort' => $effectiveAccount, 'type' => 'text'], 'detail' => ['sort' => $t . '.detail', 'type' => 'text'], 'source' => ['sort' => $t . '.source', 'type' => 'enum'], 'income' => ['sort' => $t . '.income', 'type' => 'number'], 'expense' => ['sort' => $t . '.expense', 'type' => 'number'], 'approval_no' => ['sort' => $t . '.approval_no', 'type' => 'text'], ]; } private function mappingHeaderColumns(): array { $mappingStatus = "CASE WHEN COALESCE(account_short, '') = '' THEN 'unmapped' ELSE 'mapped' END"; return [ 'source' => ['sort' => 'source', 'type' => 'enum'], 'source_value' => ['sort' => 'source_value', 'type' => 'text'], 'txn_count' => ['sort' => 'txn_count', 'type' => 'number'], 'income_sum' => ['sort' => 'income_sum', 'type' => 'number'], 'expense_sum' => ['sort' => 'expense_sum', 'type' => 'number'], 'account_short' => ['sort' => 'account_short', 'type' => 'text'], 'mapping_status' => ['sort' => $mappingStatus, 'type' => 'enum'], ]; } private function sourceHeaderColumns(): array { return [ 'source' => ['value' => 'source', 'type' => 'enum'], 'process_type' => ['value' => 'process_type', 'type' => 'text'], 'rows_count' => ['value' => 'rows_count', 'type' => 'number'], 'income' => ['value' => 'income', 'type' => 'number'], 'expense' => ['value' => 'expense', 'type' => 'number'], 'date_range' => ['value' => 'date_range', 'type' => 'text'], ]; } private function db() { return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); } private function tableName(string $table): string { return $this->db()->getTablePrefix() . $table; } private function queryAlias(string $alias): string { return $this->db()->getTablePrefix() . $alias; } }