'钉钉审批', 'shop' => '店铺提现', 'shop_bill' => '店铺账单提现', 'payable' => '供应商应付', 'store' => '店铺开店', 'workorder' => '聚工单', 'manual' => '手工录入', ]; public function pageData(?Request $request = null): array { $request ??= request(); $context = $this->context($request); $filters = $context['filters']; $companies = $context['companies']; $shopRows = $context['shops']; $accountRows = $context['accounts']; $selection = $context['selection']; $filters['company'] = $selection['company']; $filters['shop'] = $selection['shop']['shop_name'] ?? ''; $filters['account'] = $selection['account_short']; $summary = [ 'income_count' => 0, 'income' => 0.0, 'withdraw_count' => 0, 'pending_count' => 0, 'expense_count' => 0, 'expense' => 0.0, ]; $rows = []; $openingBalance = null; $closingBalance = null; if ($selection['account_short'] !== '') { $transactions = $this->transactions( $selection['account_short'], $filters['start'], $filters['end'], ); $accountMappings = $this->accountMappings($transactions); $relatedShops = $selection['related_shops']; $runningBalance = $this->openingBalance( $selection['account_short'], $filters['start'], ); $openingBalance = $runningBalance; foreach ($transactions as $transaction) { $income = round((float) ($transaction->income ?? 0), 2); $expense = round((float) ($transaction->expense ?? 0), 2); $source = trim((string) ($transaction->source ?? '')); $sourceValue = trim((string) ($transaction->source_value ?? '')); $isIncome = $income > 0; $isDingTalkExpense = $source === 'dingtalk' && $expense > 0; if (! $isIncome && ! $isDingTalkExpense) { continue; } if ($runningBalance !== null) { $runningBalance = round($runningBalance + $income - ($isDingTalkExpense ? $expense : 0), 2); } $type = $this->rowType($isIncome, $source, $sourceValue); $shopName = $this->attributeShop( $source, $sourceValue, (string) ($transaction->detail ?? ''), (string) ($transaction->approval_no ?? ''), $selection['account_short'], $relatedShops, $accountMappings, ); if ($isIncome) { $summary['income_count']++; $summary['income'] = round($summary['income'] + $income, 2); if (in_array($source, self::WITHDRAW_SOURCES, true)) { $summary['withdraw_count']++; if ($sourceValue === '') { $summary['pending_count']++; } } } if ($isDingTalkExpense) { $summary['expense_count']++; $summary['expense'] = round($summary['expense'] + $expense, 2); } $rows[] = [ 'id' => (int) $transaction->id, 'txn_date' => substr((string) $transaction->txn_date, 0, 10), 'direction' => $isIncome ? 'income' : 'expense', 'type_key' => $type['key'], 'type_label' => $type['label'], 'match_status' => $type['match_status'], 'detail' => trim((string) ($transaction->detail ?? '')), 'process_type' => trim((string) ($transaction->process_type ?? '')), 'source' => $source, 'source_value' => $sourceValue, 'approval_no' => trim((string) ($transaction->approval_no ?? '')), 'shop_name' => $shopName, 'income' => $isIncome ? $income : 0.0, 'expense' => $isDingTalkExpense ? $expense : 0.0, 'balance' => $runningBalance, ]; } $closingBalance = $this->closingBalance($selection['account_short'], $filters['end']); } $filteredRows = array_values(array_filter( $rows, static fn (array $row): bool => self::matchesDisplayFilter($row, $filters), )); usort($filteredRows, static fn (array $left, array $right): int => [ $right['txn_date'], $right['id'], ] <=> [ $left['txn_date'], $left['id'], ]); $currentPage = max(1, (int) $request->query('shop_flows_page', 1)); $pageRows = array_slice( $filteredRows, ($currentPage - 1) * self::PER_PAGE, self::PER_PAGE, ); $paginator = new LengthAwarePaginator( $pageRows, count($filteredRows), self::PER_PAGE, $currentPage, [ 'path' => $request->url(), 'query' => $request->query(), 'pageName' => 'shop_flows_page', ], ); $balanceCheck = $this->balanceCheck( $openingBalance, $summary['income'], $summary['expense'], $closingBalance, ); return [ 'shopFlowFilters' => $filters, 'shopFlowCompanies' => $companies['options'], 'shopFlowShops' => array_values($shopRows), 'shopFlowAccounts' => array_values($accountRows), 'shopFlowSelectedShop' => $selection['shop'], 'shopFlowSelectedAccount' => $selection['account'], 'shopFlowSelectedAccountShort' => $selection['account_short'], 'shopFlowRelatedShops' => $selection['related_shops'], 'shopFlowSummary' => $summary, 'shopFlowRows' => $paginator, 'shopFlowBalance' => $balanceCheck, 'shopFlowPageUrl' => $this->pageUrl(), 'shopFlowResetUrl' => $this->pageUrl(), 'shopFlowUnlockUrl' => $this->unlockUrl($selection, $filters), 'shopFlowExportUrl' => $this->exportUrl($filters), ]; } /** * 按库游标流式导出当前筛选结果,避免全量结果驻留内存。 */ public function export(Request $request): StreamedResponse { $context = $this->context($request); $filters = $context['filters']; $selection = $context['selection']; $fileName = '店铺收支明细-' . str_replace('-', '', $filters['start']) . '-' . str_replace('-', '', $filters['end']) . '.csv'; return response()->streamDownload(function () use ($filters, $selection): void { $output = fopen('php://output', 'w'); fwrite($output, chr(0xEF) . chr(0xBB) . chr(0xBF)); fputcsv($output, ['日期', '方向·类型', '摘要', '归属店铺', '来源单据', '收入', '支出', '余额']); if ($selection['account_short'] === '') { fclose($output); return; } $relatedShops = $selection['related_shops']; $accountMappings = $this->accountMappingsForPeriod( $selection['account_short'], $filters['start'], $filters['end'], ); $balance = $this->openingBalance($selection['account_short'], $filters['start']); $rows = $this->transactionQuery( $selection['account_short'], $filters['start'], $filters['end'], ) ->orderBy('t.txn_date') ->orderBy('t.id') ->select($this->transactionColumns()) ->cursor(); foreach ($rows as $transaction) { $income = round((float) ($transaction->income ?? 0), 2); $expense = round((float) ($transaction->expense ?? 0), 2); $source = trim((string) ($transaction->source ?? '')); $sourceValue = trim((string) ($transaction->source_value ?? '')); $isIncome = $income > 0; $isDingTalkExpense = $source === 'dingtalk' && $expense > 0; if (! $isIncome && ! $isDingTalkExpense) { continue; } if ($balance !== null) { $balance = round($balance + $income - ($isDingTalkExpense ? $expense : 0), 2); } $type = $this->rowType($isIncome, $source, $sourceValue); $shopName = $this->attributeShop( $source, $sourceValue, (string) ($transaction->detail ?? ''), (string) ($transaction->approval_no ?? ''), $selection['account_short'], $relatedShops, $accountMappings, ); $row = [ 'direction' => $isIncome ? 'income' : 'expense', 'source' => $source, ]; if (! self::matchesDisplayFilter($row, $filters)) { continue; } $typeText = $type['match_status'] === '' ? $type['label'] : $type['label'] . '(' . $type['match_status'] . ')'; fputcsv($output, [ substr((string) $transaction->txn_date, 0, 10), $this->csvValue($typeText), $this->csvValue((string) ($transaction->detail ?? '')), $this->csvValue($shopName), $this->csvValue((string) ($transaction->approval_no ?? '')), $isIncome ? '¥' . number_format($income, 2) : '', $isDingTalkExpense ? '¥' . number_format($expense, 2) : '', $balance === null ? '' : '¥' . number_format($balance, 2), ]); } fclose($output); }, $fileName, [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', ]); } private function context(Request $request): array { $filters = $this->filters($request); $companies = $this->companies(); $shops = $this->shopRows(); $accounts = $this->accountRows(); return [ 'filters' => $filters, 'companies' => $companies, 'shops' => $shops, 'accounts' => $accounts, 'selection' => $this->selection($filters, $companies, $shops, $accounts), ]; } private function filters(Request $request): array { $start = $this->normalizeDate((string) $request->query('start', '')); $end = $this->normalizeDate((string) $request->query('end', '')); $start ??= now()->startOfMonth()->toDateString(); $end ??= now()->toDateString(); if ($start > $end) { [$start, $end] = [$end, $start]; } $direction = trim((string) $request->query('direction', 'all')); $sourceType = trim((string) $request->query('source_type', 'all')); return [ 'company' => trim((string) $request->query('company', '')), 'shop' => trim((string) $request->query('shop', '')), 'account' => trim((string) $request->query('account', '')), 'start' => $start, 'end' => $end, 'direction' => in_array($direction, self::DIRECTIONS, true) ? $direction : 'all', 'source_type' => in_array($sourceType, self::SOURCE_TYPES, true) ? $sourceType : 'all', ]; } private function companies(): array { $companyNames = $this->db()->table('fin_companies') ->orderBy('name') ->pluck('name') ->merge( $this->db()->table('fin_shops') ->whereNotNull('company_name') ->where('company_name', '<>', '') ->distinct() ->pluck('company_name'), ) ->merge( $this->db()->table('fin_accounts') ->whereNotNull('company_name') ->where('company_name', '<>', '') ->distinct() ->pluck('company_name'), ); $options = $companyNames ->map(static fn (mixed $name): string => trim((string) $name)) ->filter() ->unique() ->sort(static fn (string $left, string $right): int => strnatcasecmp($left, $right)) ->values() ->all(); return ['options' => $options]; } private function shopRows(): array { return $this->db()->table('fin_shops') ->orderBy('company_name') ->orderBy('shop_name') ->get([ 'shop_name', 'platform', 'company_name', 'account_short', 'status', 'payout_account', ]) ->map(static function ($row): array { return [ 'shop_name' => trim((string) $row->shop_name), 'platform' => trim((string) ($row->platform ?? '')), 'company_name' => trim((string) ($row->company_name ?? '')), 'account_short' => trim((string) ($row->account_short ?? '')), 'status' => trim((string) ($row->status ?? '')), 'payout_account' => trim((string) ($row->payout_account ?? '')), ]; }) ->filter(static fn (array $row): bool => $row['shop_name'] !== '') ->keyBy('shop_name') ->all(); } private function accountRows(): array { return $this->db()->table('fin_accounts') ->orderBy('company_name') ->orderBy('account_short') ->orderBy('id') ->get([ 'account_short', 'company_name', 'account_no', 'bank_name', 'account_last4', 'current_balance', 'owner', ]) ->filter(static fn ($row): bool => trim((string) $row->account_short) !== '') ->groupBy(static fn ($row): string => trim((string) $row->account_short)) ->map(static function ($rows, string $accountShort): array { $row = $rows->first(); return [ 'account_short' => $accountShort, 'company_name' => trim((string) ($row->company_name ?? '')), 'account_no' => trim((string) ($row->account_no ?? '')), 'bank_name' => trim((string) ($row->bank_name ?? '')), 'account_last4' => trim((string) ($row->account_last4 ?? '')), 'current_balance' => round((float) ($row->current_balance ?? 0), 2), 'owner' => trim((string) ($row->owner ?? '')), ]; }) ->all(); } private function selection( array $filters, array $companies, array $shops, array $accounts, ): array { $company = in_array($filters['company'], $companies['options'], true) ? $filters['company'] : ''; $shop = $shops[$filters['shop']] ?? null; $accountShort = ''; if ($shop !== null) { $company = $shop['company_name'] !== '' ? $shop['company_name'] : $company; $accountShort = $shop['account_short']; } elseif ($filters['account'] !== '') { $candidate = $accounts[$filters['account']] ?? null; if ($candidate !== null && ($company === '' || $candidate['company_name'] === $company)) { $accountShort = $candidate['account_short']; } } $account = $accountShort !== '' ? ($accounts[$accountShort] ?? null) : null; $relatedShops = $accountShort === '' ? [] : array_values(array_filter( $shops, static fn (array $row): bool => $row['account_short'] === $accountShort, )); return [ 'company' => $company, 'shop' => $shop, 'account' => $account, 'account_short' => $accountShort, 'related_shops' => $relatedShops, ]; } private function transactions(string $accountShort, string $start, string $end): array { return $this->transactionQuery($accountShort, $start, $end) ->orderBy('t.txn_date') ->orderBy('t.id') ->get($this->transactionColumns()) ->all(); } private function transactionQuery(string $accountShort, string $start, string $end) { // 钉钉付款的原始 account_short 常为空,按账户映射解析;无映射时保留原值兜底。 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'); }) ->where(function ($query) use ($accountShort): void { $query->where(function ($mapped) use ($accountShort): void { $mapped->whereNotNull('m.account_short') ->where('m.account_short', '<>', '') ->where('m.account_short', $accountShort); })->orWhere(function ($direct) use ($accountShort): void { $direct->where(function ($mapping): void { $mapping->whereNull('m.account_short')->orWhere('m.account_short', ''); })->where('t.account_short', $accountShort); }); }) ->whereBetween('t.txn_date', [$start, $end]) ->where(function ($query): void { $query->where('t.income', '>', 0) ->orWhere(function ($expense): void { $expense->where('t.source', 'dingtalk')->where('t.expense', '>', 0); }); }); } private function transactionColumns(): array { return [ 't.id', 't.txn_date', 't.detail', 't.income', 't.expense', 't.approval_no', 't.source', 't.source_value', 't.process_type', ]; } private function accountMappings(array $transactions): array { $sourceValues = []; $sources = []; foreach ($transactions as $transaction) { $source = trim((string) ($transaction->source ?? '')); $sourceValue = trim((string) ($transaction->source_value ?? '')); if ($source === '' || $sourceValue === '') { continue; } $sources[$source] = true; $sourceValues[$sourceValue] = true; } if ($sources === [] || $sourceValues === []) { return []; } $mappings = []; $rows = $this->db()->table('fin_account_map') ->whereIn('source', array_keys($sources)) ->whereIn('source_value', array_keys($sourceValues)) ->get(['source', 'source_value', 'account_short']); foreach ($rows as $row) { $mappings[$row->source . "\0" . $row->source_value] = trim((string) ($row->account_short ?? '')); } return $mappings; } private function accountMappingsForPeriod(string $accountShort, string $start, string $end): array { $transactions = $this->transactionQuery($accountShort, $start, $end) ->reorder() ->select('t.source', 't.source_value') ->distinct() ->get() ->all(); return $this->accountMappings($transactions); } private function openingBalance(string $accountShort, string $start): ?float { $previousDate = date('Y-m-d', strtotime($start . ' -1 day')); $closing = $this->db()->table('fin_daily_flows') ->where('account_short', $accountShort) ->where('flow_date', $previousDate) ->value('closing_balance'); if ($closing !== null) { return round((float) $closing, 2); } $opening = $this->db()->table('fin_account_opening') ->where('account_short', $accountShort) ->where('opening_date', '<=', $start) ->orderByDesc('opening_date') ->orderByDesc('id') ->value('opening_balance'); return $opening === null ? null : round((float) $opening, 2); } private function closingBalance(string $accountShort, string $end): ?float { $closing = $this->db()->table('fin_daily_flows') ->where('account_short', $accountShort) ->where('flow_date', $end) ->value('closing_balance'); return $closing === null ? null : round((float) $closing, 2); } private function rowType(bool $isIncome, string $source, string $sourceValue): array { if ($isIncome && in_array($source, self::WITHDRAW_SOURCES, true)) { return [ 'key' => 'income_withdraw', 'label' => '收入·提现', 'match_status' => $sourceValue === '' ? '待匹配提现单' : '已匹配提现单', ]; } if ($isIncome) { return [ 'key' => 'income_other', 'label' => '收入·其他', 'match_status' => '', ]; } return [ 'key' => 'expense_dingtalk', 'label' => '支出·钉钉流程', 'match_status' => '', ]; } private function attributeShop( string $source, string $sourceValue, string $detail, string $approvalNo, string $accountShort, array $relatedShops, array $accountMappings, ): string { $byNormalizedName = []; foreach ($relatedShops as $shop) { $byNormalizedName[$this->normalizeShopName($shop['shop_name'])] = $shop['shop_name']; } $normalizedSourceValue = $this->normalizeShopName($sourceValue); if ($normalizedSourceValue !== '' && isset($byNormalizedName[$normalizedSourceValue])) { return $byNormalizedName[$normalizedSourceValue]; } $normalizedApproval = $this->normalizeShopName($approvalNo); if ($normalizedApproval !== '' && isset($byNormalizedName[$normalizedApproval])) { return $byNormalizedName[$normalizedApproval]; } $normalizedDetail = $this->normalizeShopName($detail); $detailMatches = []; foreach ($byNormalizedName as $normalizedName => $shopName) { if ($normalizedName !== '' && str_contains($normalizedDetail, $normalizedName)) { $detailMatches[] = $shopName; } } if (count($detailMatches) === 1) { return $detailMatches[0]; } if ($sourceValue !== '') { $mappedAccount = $accountMappings[$source . "\0" . $sourceValue] ?? ''; if ($mappedAccount === $accountShort && count($relatedShops) === 1) { return $relatedShops[0]['shop_name']; } } return count($relatedShops) === 1 ? $relatedShops[0]['shop_name'] : ''; } private function normalizeShopName(string $value): string { $value = mb_strtolower(trim($value), 'UTF-8'); return preg_replace('/[\s\x{3000}\-_\/\\\\·•()()【】\[\]]+/u', '', $value) ?? $value; } private static function matchesDisplayFilter(array $row, array $filters): bool { if ($filters['direction'] === 'income' && $row['direction'] !== 'income') { return false; } if ($filters['direction'] === 'expense' && $row['direction'] !== 'expense') { return false; } return match ($filters['source_type']) { 'shop' => $row['direction'] === 'income' && in_array($row['source'], self::WITHDRAW_SOURCES, true), 'other' => $row['direction'] === 'income' && ! in_array($row['source'], [...self::WITHDRAW_SOURCES, 'dingtalk'], true), 'dingtalk' => $row['direction'] === 'expense' && $row['source'] === 'dingtalk', default => true, }; } private function balanceCheck( ?float $opening, float $income, float $expense, ?float $closing, ): array { $calculated = $opening === null ? null : round($opening + $income - $expense, 2); if ($opening === null) { return [ 'opening' => null, 'closing' => $closing, 'calculated_closing' => null, 'difference' => null, 'status' => 'opening_missing', 'message' => '期初余额未维护,无法完成余额恒等式自检。', ]; } if ($closing === null) { return [ 'opening' => $opening, 'closing' => null, 'calculated_closing' => $calculated, 'difference' => null, 'status' => 'closing_missing', 'message' => '期间末日没有每日余额快照,期初与本期收支已汇总,暂无法完成余额自检。', ]; } $difference = round($calculated - $closing, 2); if (abs($difference) > 0.01) { return [ 'opening' => $opening, 'closing' => $closing, 'calculated_closing' => $calculated, 'difference' => $difference, 'status' => 'mismatch', 'message' => '余额恒等式不一致,差额 ' . number_format($difference, 2) . ' 元,请核对流水来源与每日余额。', ]; } return [ 'opening' => $opening, 'closing' => $closing, 'calculated_closing' => $calculated, 'difference' => 0.0, 'status' => 'matched', 'message' => '余额恒等式校验通过:期初 + 本期收入 − 本期支出 = 期末。', ]; } private function unlockUrl(array $selection, array $filters): string { if (($selection['shop'] ?? null) === null || $selection['account_short'] === '') { return ''; } return $this->pageUrl([ 'company' => $selection['company'], 'account' => $selection['account_short'], 'start' => $filters['start'], 'end' => $filters['end'], 'direction' => $filters['direction'], 'source_type' => $filters['source_type'], ]); } private function pageUrl(array $query = []): string { $url = route('business.pages.show', [ 'section' => 'financial-operations', 'page' => 'shop-flows', ]); return $query === [] ? $url : $url . '?' . http_build_query(array_filter( $query, static fn (mixed $value): bool => $value !== null && $value !== '', )); } private function exportUrl(array $filters): string { $url = route('business.pages.export', [ 'section' => 'financial-operations', 'page' => 'shop-flows', 'tab' => 'shop-flows', ]); return $url . '?' . http_build_query(array_filter([ 'company' => $filters['company'], 'shop' => $filters['shop'], 'account' => $filters['account'], 'start' => $filters['start'], 'end' => $filters['end'], 'direction' => $filters['direction'], 'source_type' => $filters['source_type'], ], static fn (mixed $value): bool => $value !== null && $value !== '')); } private function csvValue(string $value): string { if (preg_match('/^[=+\-@]/', $value) === 1) { return "'" . $value; } return $value; } private function normalizeDate(string $value): ?string { $value = trim($value); if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) { return null; } [$year, $month, $day] = array_map('intval', explode('-', $value)); if (! checkdate($month, $day, $year)) { return null; } return sprintf('%04d-%02d-%02d', $year, $month, $day); } private function db() { return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); } }