authorizeStoreReceivingBills(); $billType = $bill_type; if (! AccountBillColumns::has($billType)) { abort(404, '未知的账户类型。'); } if ($billType === 'douyin') { return $request->boolean('export') ? $this->export($request, $billType) : $this->douyinIndex($request); } $request->validate([ 'store_name' => ['nullable', 'string', 'max:255'], 'bill_item' => ['nullable', 'string', 'max:255'], 'start' => ['nullable', 'date_format:Y-m-d'], 'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'], 'keyword' => ['nullable', 'string', 'max:255'], 'export' => ['nullable', 'boolean'], 'columns' => ['nullable', 'array', 'max:40'], 'columns.*' => ['string', 'max:128'], ]); $query = AccountBill::query() ->where('company_id', company_id()) ->where('bill_type', $billType); if ($store = $request->get('store_name')) { $query->where('store_name', $store); } if ($item = $request->get('bill_item')) { $query->where('bill_item', $item); } if ($start = $request->get('start')) { $query->where('occurred_at', '>=', $start); } if ($end = $request->get('end')) { $query->where('occurred_at', '<=', $end . ' 23:59:59'); } if ($keyword = $request->get('keyword')) { $query->where(function ($q) use ($keyword) { $like = '%' . $keyword . '%'; $q->where('record_code', 'like', $like) ->orWhere('order_no', 'like', $like) ->orWhere('remark', 'like', $like) ->orWhere('bill_item', 'like', $like); }); } $sortable = ['occurred_at', 'income', 'expense', 'balance', 'record_code']; $sort = in_array($request->get('sort'), $sortable, true) ? $request->get('sort') : 'occurred_at'; $dir = $request->get('dir') === 'asc' ? 'asc' : 'desc'; $query->orderBy($sort, $dir); if ($request->boolean('export')) { return $this->exportQuery($request, $query, $billType); } $perPage = min(max((int) $request->get('limit', 10), 1), 100); $rows = $query->paginate($perPage); // 汇总(分页前) $summaryRow = (clone $query)->selectRaw( 'COALESCE(SUM(income), 0) as income, ' . 'COALESCE(SUM(expense), 0) as expense, ' . 'COUNT(*) as count' )->first(); $latestBalance = (clone $query) ->orderByDesc('occurred_at') ->orderByDesc('id') ->value('balance'); $data = $rows->toArray(); $data['summary'] = [ 'income' => (float) ($summaryRow->income ?? 0), 'expense' => (float) ($summaryRow->expense ?? 0), 'count' => (int) ($summaryRow->count ?? 0), 'balance' => $latestBalance !== null ? (float) $latestBalance : 0, ]; $data['columns'] = AccountBillColumns::for($billType); return response()->json($data); } public function export(Request $request, string $bill_type) { $this->authorizeStoreReceivingBills(); abort_unless(AccountBillColumns::has($bill_type), 404, '未知的账户类型。'); if ($bill_type === 'douyin') { return $this->exportDouyin($request); } $filters = $request->validate([ 'store_name' => ['nullable', 'string', 'max:255'], 'bill_item' => ['nullable', 'string', 'max:255'], 'start' => ['nullable', 'date_format:Y-m-d'], 'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'], 'keyword' => ['nullable', 'string', 'max:255'], ]); $query = $this->filteredQuery($request, $bill_type); $sortable = ['occurred_at', 'income', 'expense', 'balance', 'record_code']; $sort = in_array($request->get('sort'), $sortable, true) ? $request->get('sort') : 'occurred_at'; $dir = $request->get('dir') === 'asc' ? 'asc' : 'desc'; $query->orderBy($sort, $dir); return $this->exportQuery($request, $query, $bill_type); } private function exportQuery(Request $request, $query, string $billType) { $definition = AccountBillColumns::for($billType); $available = collect($definition['columns'] ?? [])->keyBy('field'); $requested = $request->input('columns', []); if (is_string($requested)) { $requested = array_filter(array_map('trim', explode(',', $requested))); } if (! is_array($requested)) { $requested = []; } $columns = $requested === [] ? $available->values() : collect($requested)->unique()->map(fn (string $field) => $available->get($field))->filter()->values(); abort_if($columns->isEmpty(), 422, '至少选择一个可导出的字段。'); return response()->streamDownload(function () use ($query, $columns): void { $csv = fopen('php://output', 'w'); fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); fputcsv($csv, $columns->pluck('label')->all()); foreach ($query->cursor() as $row) { fputcsv($csv, $columns->map(function (array $column) use ($row): string { $value = $this->columnValue($row, $column['field']); return $this->csvValue($value, $column['kind']); })->all()); } fclose($csv); }, $billType . '_account_bills.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', ]); } private function columnValue(AccountBill $row, string $field): mixed { if (str_starts_with($field, 'payload.')) { return data_get($row->payload ?? [], substr($field, 8), ''); } return $row->getAttribute($field) ?? ''; } private function csvValue(mixed $value, string $kind): string { $text = (string) ($value ?? ''); if ($kind === 'num' && preg_match('/^\d{12,}$/', $text)) { return "\t" . $text; } return preg_match('/^[=+\-@]/', $text) ? "'" . $text : $text; } private function filteredQuery(Request $request, string $billType) { $query = AccountBill::query() ->where('company_id', company_id()) ->where('bill_type', $billType); if ($store = $request->get('store_name')) { $query->where('store_name', $store); } if ($item = $request->get('bill_item')) { $query->where('bill_item', $item); } if ($start = $request->get('start')) { $query->where('occurred_at', '>=', $start); } if ($end = $request->get('end')) { $query->where('occurred_at', '<=', $end . ' 23:59:59'); } if ($keyword = $request->get('keyword')) { $query->where(function ($q) use ($keyword): void { $like = '%' . $keyword . '%'; $q->where('record_code', 'like', $like) ->orWhere('order_no', 'like', $like) ->orWhere('remark', 'like', $like) ->orWhere('bill_item', 'like', $like); }); } return $query; } private function douyinIndex(Request $request) { $filters = $this->douyinFilters($request); $sort = $this->sort($request); $direction = $this->direction($request); $page = max((int) $request->get('page', 1), 1); $perPage = min(max((int) $request->get('limit', 10), 1), 100); try { $data = app(StoreReceivingDouyinBillMirrorRepository::class)->paginate( $filters, $sort, $direction, $page, $perPage, ); } catch (RuntimeException $exception) { if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') { throw $exception; } return response()->json([ 'message' => '抖音账户账单共享业务镜像正在首次同步,完成后将自动显示。', 'data' => [], 'total' => 0, 'current_page' => 1, 'last_page' => 1, 'per_page' => $perPage, 'summary' => ['income' => 0, 'expense' => 0, 'balance' => 0, 'count' => 0], 'columns' => AccountBillColumns::for('douyin'), ], 503); } $data['columns'] = AccountBillColumns::for('douyin'); return response()->json($data); } private function exportDouyin(Request $request) { $filters = $this->douyinFilters($request); $definition = AccountBillColumns::for('douyin'); $columns = collect($definition['columns'] ?? []); $requested = $request->query('columns', []); if (is_string($requested)) { $requested = array_filter(array_map('trim', explode(',', $requested))); } if (! is_array($requested)) { $requested = []; } $requested = collect($requested)->map(static fn (string $field): string => trim($field))->filter()->unique()->values(); if ($requested->isEmpty()) { $selected = $columns; } else { $columnsByField = $columns->keyBy('field'); $selected = $requested ->map(static fn (string $field) => $columnsByField->get($field)) ->filter(static fn ($column): bool => is_array($column)) ->values(); abort_if($selected->isEmpty(), 422, '未选择有效的导出字段。'); } $sort = $this->sort($request); $direction = $this->direction($request); try { $rows = app(StoreReceivingDouyinBillMirrorRepository::class) ->exportRows($filters, $sort, $direction); } catch (RuntimeException $exception) { if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') { throw $exception; } return response()->json(['message' => '抖音账户账单共享业务镜像正在首次同步,完成后将自动导出。'], 503); } $headers = $selected->pluck('label')->all(); return response()->streamDownload(function () use ($rows, $selected, $headers): void { $csv = fopen('php://output', 'w'); fputs($csv, "\xEF\xBB\xBF"); fputcsv($csv, $headers); foreach ($rows as $row) { fputcsv($csv, $selected->map( fn (array $column): string => $this->exportArrayValue($row, $column) )->all()); } fclose($csv); }, 'douyin_account_bills.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', ]); } /** @return array */ private function douyinFilters(Request $request): array { $filters = $request->validate([ 'store_name' => ['nullable', 'string', 'max:255'], 'bill_item' => ['nullable', 'string', 'max:255'], 'start' => ['nullable', 'date_format:Y-m-d'], 'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'], 'keyword' => ['nullable', 'string', 'max:255'], ]); return array_map(static fn (mixed $value): string => trim((string) $value), $filters); } private function sort(Request $request): string { return in_array($request->get('sort'), ['occurred_at', 'income', 'expense', 'balance', 'record_code'], true) ? (string) $request->get('sort') : 'occurred_at'; } private function direction(Request $request): string { return $request->get('dir') === 'asc' ? 'asc' : 'desc'; } private function exportArrayValue(array $row, array $column): string { $field = (string) ($column['field'] ?? ''); $value = str_starts_with($field, 'payload.') ? data_get($row['payload'] ?? [], substr($field, 8)) : data_get($row, $field); $text = trim((string) ($value ?? '')); if (preg_match('/^\d{12,}$/', $text) === 1) { return '="' . $text . '"'; } return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text; } public function show(AccountBill $bill) { $this->authorizeStoreReceivingBills(); return response()->json($bill); } protected function authorizeStoreReceivingBills(): void { $section = collect(SidebarPermissions::navigationFor(user())) ->firstWhere('slug', 'billing-transactions'); $page = collect($section['pages'] ?? []) ->firstWhere('slug', 'store-receiving-bills'); abort_if(empty($page), 403, '当前账号没有店铺收款账户账单权限。'); } }