authorizeStoreReceivingBills(); $billType = $bill_type; if (! AccountBillColumns::has($billType)) { abort(404, '未知的账户类型。'); } if (in_array($billType, self::MIRROR_TABS, true)) { return $request->boolean('export') ? $this->export($request, $billType) : $this->mirrorIndex($request, $billType); } $request->validate([ 'store_name' => ['nullable', 'string', 'max:255'], 'ledger_remark' => ['nullable', 'string', 'max:191'], '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 = $this->filteredQuery($request, $billType); $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['data'] = collect($rows->items()) ->map(fn (AccountBill $bill): array => $this->billRow($bill)) ->all(); $income = (float) ($summaryRow->income ?? 0); $expense = (float) ($summaryRow->expense ?? 0); $data['summary'] = [ 'income' => $income, 'expense' => $expense, 'count' => (int) ($summaryRow->count ?? 0), // 唯品会JX账单没有账户余额列,该页签以收支净额作为汇总口径。 'balance' => $billType === 'vipshop-jx' ? round($income - $expense, 2) : ($latestBalance !== null ? (float) $latestBalance : 0), ]; $data['columns'] = AccountBillColumns::for($billType); return response()->json($this->withLedgerRemarks($data, $billType)); } public function export(Request $request, string $bill_type) { $this->authorizeStoreReceivingBills(); // 导出进度:前端带 token(+列表总数)发起下载,导出过程中把已导出行数写进缓存,前端轮询显示。 $exportToken = $this->exportProgressToken($request); $exportTotal = max(0, (int) $request->get('total', 0)); $this->putExportProgress($exportToken, 0, $exportTotal); abort_unless(AccountBillColumns::has($bill_type), 404, '未知的账户类型。'); if (in_array($bill_type, self::MIRROR_TABS, true)) { return $this->exportMirror($request, $bill_type); } $filters = $request->validate([ 'store_name' => ['nullable', 'string', 'max:255'], 'ledger_remark' => ['nullable', 'string', 'max:191'], '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); } public function filterOptions(string $bill_type) { $this->authorizeStoreReceivingBills(); $ledgerRemarks = $this->ledgerRemarkMap()->remarksForTab($bill_type); if (in_array($bill_type, ['vipshop', 'vipshop-jx'], true)) { $query = AccountBill::query() ->where('company_id', company_id()) ->where('bill_type', $bill_type); return response()->json([ 'shops' => (clone $query)->whereNotNull('store_name')->where('store_name', '<>', '') ->distinct()->orderBy('store_name')->pluck('store_name')->all(), // 唯品会JX 的「账单项目」是账单行里的业务备注(处罚款 / 客服特殊退款-仅退款 / 盘盈盘亏 …), // 与页面「账单项目」列同源;MP 页签仍按账单项目列取值。 'bill_items' => $bill_type === 'vipshop-jx' ? (clone $query) ->whereRaw("COALESCE(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.second_remark')), '') <> ''") ->selectRaw("DISTINCT JSON_UNQUOTE(JSON_EXTRACT(payload, '$.second_remark')) AS item") ->orderBy('item') ->pluck('item')->all() : (clone $query)->whereNotNull('bill_item')->where('bill_item', '<>', '') ->distinct()->orderBy('bill_item')->pluck('bill_item')->all(), 'ledger_remarks' => $ledgerRemarks, ]); } abort_unless(in_array($bill_type, self::MIRROR_TABS, true), 404, '该账户类型没有可用的筛选选项。'); $label = $this->mirrorLabel($bill_type); try { return response()->json( $this->mirrorRepository($bill_type)->filterOptions() + ['ledger_remarks' => $ledgerRemarks] ); } catch (RuntimeException $exception) { if (! in_array($exception->getMessage(), [ 'store_receiving_alipay_bill_mirror_not_ready', 'store_receiving_bill_filter_options_busy', 'jd_daily_bill_mirror_not_ready', 'jd_wallet_bill_mirror_not_ready', ], true)) { throw $exception; } return response()->json([ 'message' => $exception->getMessage() === 'store_receiving_bill_filter_options_busy' ? $label . '账户账单筛选选项正在生成,请稍后重试。' : $label . '账户账单共享业务镜像正在首次同步,筛选选项稍后可用。', 'shops' => [], 'bill_items' => [], 'ledger_remarks' => $ledgerRemarks, ], 503); } } /** 账单表格按 Y-m-d H:i:s 展示日期,避免 Eloquent 默认序列化成 ISO8601(UTC)。 */ private function billRow(AccountBill $bill): array { return collect($bill->toArray()) ->map(function (mixed $value, string $field) use ($bill): mixed { $attribute = $bill->getAttribute($field); return $attribute instanceof \DateTimeInterface ? $attribute->format('Y-m-d H:i:s') : $value; }) ->all(); } 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 = []; } if ($requested !== []) { $requested = collect($requested)->unique()->values(); $invalid = $requested->reject(fn (string $field): bool => $available->has($field)); abort_if($invalid->isNotEmpty(), 422, '包含不可导出的字段:' . $invalid->implode('、')); } $columns = $requested === [] ? $available->values() : $requested->map(fn (string $field) => $available->get($field))->values(); abort_if($columns->isEmpty(), 422, '至少选择一个可导出的字段。'); return response()->streamDownload(function () use ($query, $columns, $exportToken, $exportTotal): void { // 大账期逐行生成较慢:先发文件头并关掉输出缓冲,让浏览器立刻开始下载。 while (ob_get_level() > 0) { ob_end_flush(); } $csv = fopen('php://output', 'w'); fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); fputcsv($csv, $columns->pluck('label')->all()); flush(); $written = 0; 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()); $written++; if ($written % 5000 === 0) { $this->putExportProgress($exportToken, $written, $exportTotal); } } fclose($csv); $this->putExportProgress($exportToken, $written, $exportTotal, 'done'); }, $billType . '_account_bills.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', 'X-Accel-Buffering' => 'no', ]); } 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); } $ledgerFilters = $this->withLedgerRemarkFilter( ['ledger_remark' => $request->get('ledger_remark')], $billType, ); if (($ledgerFilters['bill_items'] ?? []) !== []) { // 唯品会JX 的科目映射按行内「账单项目」维护,总账备注筛选也按它匹配。 if ($billType === 'vipshop-jx') { $placeholders = implode(',', array_fill(0, count($ledgerFilters['bill_items']), '?')); $query->whereRaw( "JSON_UNQUOTE(JSON_EXTRACT(payload, '$.second_remark')) IN ({$placeholders})", array_values($ledgerFilters['bill_items']), ); } else { $query->whereIn('bill_item', $ledgerFilters['bill_items']); } } if ($item = $request->get('bill_item')) { if ($billType === 'vipshop-jx') { // 唯品会JX 的账单项目筛选按行内业务备注匹配,和页面「账单项目」列一致。 $query->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(payload, '$.second_remark')) = ?", [$item]); } else { $query->where('bill_item', $item); } } $start = $request->get('start'); $end = $request->get('end'); if (in_array($billType, self::SETTLE_PERIOD_TABS, true)) { // 账单期间是「YYYY-MM」,与日期区间按月份取交集:只要月份落在所选区间内就算命中。 $period = "JSON_UNQUOTE(JSON_EXTRACT(payload, '$.settle_period'))"; if ($start) { $query->whereRaw($period . ' >= ?', [substr((string) $start, 0, 7)]); } if ($end) { $query->whereRaw($period . ' <= ?', [substr((string) $end, 0, 7)]); } } else { if ($start) { $query->where('occurred_at', '>=', $start); } if ($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 mirrorIndex(Request $request, string $billType) { $filters = $this->mirrorFilters($request, $billType); $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); $label = $this->mirrorLabel($billType); try { $data = $this->mirrorRepository($billType)->paginate( $filters, $sort, $direction, $page, $perPage, ); } catch (RuntimeException $exception) { if (! in_array($exception->getMessage(), ['store_receiving_alipay_bill_mirror_not_ready', 'jd_daily_bill_mirror_not_ready', 'jd_wallet_bill_mirror_not_ready'], true)) { throw $exception; } return response()->json([ 'message' => $label . '账户账单共享业务镜像正在首次同步,完成后将自动显示。', 'data' => [], 'total' => 0, 'current_page' => 1, 'last_page' => 1, 'per_page' => $perPage, 'summary' => ['income' => 0, 'expense' => 0, 'balance' => 0, 'count' => 0], 'columns' => AccountBillColumns::for($billType), ], 503); } $data['columns'] = AccountBillColumns::for($billType); return response()->json($this->withLedgerRemarks($data, $billType)); } /** 导出进度查询:前端带 token 轮询,展示「已导出 X / 共 Y 行」。 */ public function exportProgress(Request $request) { $token = $this->exportProgressToken($request); abort_if($token === '', 400, '导出标识无效。'); $state = \Illuminate\Support\Facades\Cache::get(self::exportProgressKey($token)); return response()->json($state ?? ['status' => 'unknown', 'rows' => 0, 'total' => 0], 200, [ 'Cache-Control' => 'private, no-store', ]); } /** 新建后台导出任务:立刻返回,导出在后台进程里跑,页面到「导出进度」列表里看状态并下载。 */ public function createExportTask(Request $request) { $this->authorizeStoreReceivingBills(); $tab = trim((string) $request->input('tab', '')); $columns = $this->exportColumnsFor($tab); abort_if($columns === [], 422, '该页签暂不支持后台导出。'); $requested = array_filter(array_map( static fn (mixed $field): string => trim((string) $field), (array) $request->input('columns', []), )); $selected = []; foreach ($requested as $field) { if (isset($columns[$field])) { $selected[] = ['field' => $field, 'label' => $columns[$field]]; } } if ($selected === []) { foreach ($columns as $field => $label) { $selected[] = ['field' => (string) $field, 'label' => (string) $label]; } } $filters = $request->except(['columns', 'tab', '_token']); $tasks = app(\Modules\EcommerceFinance\Services\AccountBillsExportTasks::class); $task = $tasks->create( (int) user()->id, $tab, $this->mirrorLabel($tab), $filters, $selected, max(0, (int) $request->input('total', 0)), ); $tasks->dispatch($task); return response()->json(['status' => 'ok', 'token' => $task['token']], 200, [ 'Cache-Control' => 'private, no-store', ]); } /** 导出任务列表(当前用户,最近 50 条)。 */ public function exportTasks() { $this->authorizeStoreReceivingBills(); $tasks = app(\Modules\EcommerceFinance\Services\AccountBillsExportTasks::class); $userId = (int) user()->id; return response()->json([ 'status' => 'ok', 'data' => array_map(static fn (array $task): array => $tasks->present($task), $tasks->all($userId)), ], 200, ['Cache-Control' => 'private, no-store']); } /** 下载已完成的导出文件。 */ public function downloadExportTask(Request $request, string $token) { $this->authorizeStoreReceivingBills(); $tasks = app(\Modules\EcommerceFinance\Services\AccountBillsExportTasks::class); $task = $tasks->find((int) user()->id, $token); abort_if($task === null, 404, '导出任务不存在或已过期。'); abort_unless(($task['status'] ?? '') === 'done', 409, '导出尚未完成,请稍后刷新。'); $path = $tasks->csvPath($task); abort_unless(is_file($path), 404, '导出文件已过期,请重新导出。'); $name = str_replace(['/', '\\'], '-', (string) ($task['tab_label'] ?? 'account-bills')) . '_' . substr($token, 0, 8) . '.csv'; return response()->download($path, $name, ['Content-Type' => 'text/csv; charset=UTF-8']); } /** 取消导出任务:写入取消标记,后台进程检测到后停止并删除半成品文件。 */ public function cancelExportTask(Request $request, string $token) { $this->authorizeStoreReceivingBills(); $tasks = app(\Modules\EcommerceFinance\Services\AccountBillsExportTasks::class); $task = $tasks->find((int) user()->id, $token); abort_if($task === null, 404, '导出任务不存在或已过期。'); $status = (string) ($task['status'] ?? ''); abort_if(in_array($status, ['done', 'failed', 'cancelled'], true), 409, '该任务已结束,无需取消。'); // 立刻显示「已取消」(排队中的任务没有进程去改状态,必须在这里定稿); // 运行中的任务会在下一个检查点停下并删掉半成品文件。 $tasks->patch($task, [ 'cancel' => true, 'status' => 'cancelled', 'finished_at' => \Modules\EcommerceFinance\Services\AccountBillsExportTasks::beijingNow(), ]); return response()->json(['status' => 'ok'], 200, ['Cache-Control' => 'private, no-store']); } /** * 该页签可导出的字段:field => label。 * * @return array */ private function exportColumnsFor(string $tab): array { if ($tab === 'taobao-alipay') { return AlipayBills::EXPORT_COLUMNS; } $definition = AccountBillColumns::for($tab); $columns = []; foreach ((array) ($definition['columns'] ?? []) as $column) { $field = (string) ($column['field'] ?? ''); if ($field !== '') { $columns[$field] = (string) ($column['label'] ?? $field); } } return $columns; } private function exportMirror(Request $request, string $billType) { $exportToken = $this->exportProgressToken($request); $exportTotal = max(0, (int) $request->get('total', 0)); $this->putExportProgress($exportToken, 0, $exportTotal); $filters = $this->mirrorFilters($request, $billType); $definition = AccountBillColumns::for($billType); $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'); $invalid = $requested->reject(static fn (string $field): bool => $columnsByField->has($field)); abort_if($invalid->isNotEmpty(), 422, '包含不可导出的字段:' . $invalid->implode('、')); $selected = $requested->map(static fn (string $field) => $columnsByField->get($field))->values(); abort_if($selected->isEmpty(), 422, '未选择有效的导出字段。'); } $sort = $this->sort($request); $direction = $this->direction($request); try { $rows = $this->mirrorRepository($billType) ->exportRows($filters, $sort, $direction); } catch (RuntimeException $exception) { if (! in_array($exception->getMessage(), ['store_receiving_alipay_bill_mirror_not_ready', 'jd_daily_bill_mirror_not_ready', 'jd_wallet_bill_mirror_not_ready'], true)) { throw $exception; } return response()->json(['message' => $this->mirrorLabel($billType) . '账户账单共享业务镜像正在首次同步,完成后将自动导出。'], 503); } $headers = $selected->pluck('label')->all(); $rows = $this->ledgerRemarkRows($rows, $billType); return response()->streamDownload(function () use ($rows, $selected, $headers, $exportToken, $exportTotal): void { while (ob_get_level() > 0) { ob_end_flush(); } $csv = fopen('php://output', 'w'); fputs($csv, "\xEF\xBB\xBF"); fputcsv($csv, $headers); flush(); $written = 0; foreach ($rows as $row) { fputcsv($csv, $selected->map( fn (array $column): string => $this->exportArrayValue($row, $column) )->all()); $written++; if ($written % 5000 === 0) { $this->putExportProgress($exportToken, $written, $exportTotal); } } fclose($csv); $this->putExportProgress($exportToken, $written, $exportTotal, 'done'); }, $billType . '_account_bills.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', 'X-Accel-Buffering' => 'no', ]); } /** @return array */ private function mirrorFilters(Request $request, string $billType): array { $filters = $request->validate([ 'store_name' => ['nullable', 'string', 'max:255'], 'ledger_remark' => ['nullable', 'string', 'max:191'], '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'], 'keyword_match' => ['nullable', 'in:contains,exact-record'], ]); $filters = array_map(static fn (mixed $value): string => trim((string) $value), $filters); return $this->withLedgerRemarkFilter($filters, $billType); } private function mirrorRepository(string $billType): object { if (JdBillPlatform::isJdBillTab($billType)) { // 京东账单与京喜账单共用同一个京东日账单仓库,只用平台口径区分数据。 return app(JdDailyBillRepository::class) ->forPlatform(JdBillPlatform::platformForBillType($billType)); } return match ($billType) { 'jd-wallet' => app(StoreReceivingJdWalletBillRepository::class), 'pinduoduo' => app(StoreReceivingPinduoduoBillMirrorRepository::class), 'dewu' => app(StoreReceivingDewuBillMirrorRepository::class), default => app(StoreReceivingDouyinBillMirrorRepository::class), }; } private function mirrorLabel(string $billType): string { return match ($billType) { 'jd-transactions' => '京东账单', 'jingxi' => '京喜账单', 'jd-wallet' => '京东钱包', 'pinduoduo' => '拼多多', 'dewu' => '得物', 'taobao-alipay' => '淘系支付宝账户', 'taobao-wechat' => '淘系微信账户', 'vipshop' => '唯品会MP账户', 'vipshop-jx' => '唯品会JX账户', 'douyin' => '抖音账户', default => $billType, }; } 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, '当前账号没有店铺收款账户账单权限。'); } }