middleware('auth'); $this->middleware('company.identify'); } /** * 支持的营销账户平台列表 */ protected const MARKETING_PLATFORMS = [ 'taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet', 'douyin', 'dewu', 'vipshop', ]; public function home() { $section = collect(SidebarPermissions::navigationFor(user()))->first(); abort_if(empty($section), 403, '当前账号没有已授权的侧边栏功能。'); $page = collect($section['pages'] ?? [])->first(); $parameters = ['section' => $section['slug']]; if ($page) { $parameters['page'] = $page['slug']; if (! empty($page['tabs'])) { $parameters['tab'] = $page['tabs'][0]['slug']; } } return redirect()->route('business.pages.show', $parameters); } public function show(string $section, ?string $page = null, ?string $tab = null) { $sectionConfig = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', $section); abort_if(empty($sectionConfig), 403, '当前账号没有该页面权限。'); $pages = collect($sectionConfig['pages'] ?? []); if ($pages->isEmpty()) { abort_if($page !== null || $tab !== null, 404); return view('business.pages.show', $this->viewData($sectionConfig)); } if ($page === null) { return $this->redirectToPage($sectionConfig, $pages->first()); } $pageConfig = $pages ->flatMap(fn (array $configuredPage) => [ $configuredPage, ...($configuredPage['children'] ?? []), ]) ->firstWhere('slug', $page); abort_if(empty($pageConfig), 404); if (! empty($pageConfig['children']) && empty($pageConfig['tabs'])) { return $this->redirectToPage($sectionConfig, $pageConfig['children'][0]); } $tabs = collect($pageConfig['tabs'] ?? []); if ($tabs->isEmpty()) { abort_if($tab !== null, 404); return view('business.pages.show', $this->viewData( $sectionConfig, $pageConfig, $tabs, null, $this->resolveContent($page, null), )); } if ($tab === null) { return $this->redirectToPage($sectionConfig, $pageConfig); } $activeTab = $tabs->firstWhere('slug', $tab); abort_if(empty($activeTab), 404); return view('business.pages.show', $this->viewData( $sectionConfig, $pageConfig, $tabs, $activeTab, $this->resolveContent($page, $tab), )); } protected function viewData( array $section, ?array $page = null, $tabs = null, ?array $activeTab = null, ?array $content = null, ): array { $descriptor = null; $rows = collect(); $tabValue = null; $tabColumn = null; $upstreamError = null; if ($page && $content === null) { $descriptor = TableRegistry::describe( $section['slug'], $page['slug'], $activeTab['slug'] ?? null, ); if ($descriptor) { if ($descriptor->readOnly) { try { $rows = app(UpstreamBusinessInformation::class)->paginate( $section['slug'], $page['slug'], 50, ); } catch (ServiceUnavailableHttpException $exception) { // 首份快照尚未生成或某个来源暂时不可用时,保留页面、 // 交代原因并允许后台刷新,而不是把整个业务页变成通用 503。 $rows = new LengthAwarePaginator([], 0, 50, 1, [ 'path' => request()->url(), 'query' => request()->query(), ]); $upstreamError = $exception->getMessage(); } return [ 'section' => $section, 'page' => $page, 'tabs' => $tabs ?? collect(), 'activeTab' => $activeTab, 'content' => $content, 'descriptor' => $descriptor, 'rows' => $rows, 'upstreamError' => $upstreamError, 'tabColumn' => null, 'tabValue' => null, 'edgeBanding' => $this->edgeBandingContent($page['slug']), 'inventoryData' => $this->inventoryContent($page['slug']), 'rawBoardData' => $this->rawBoardContent($page['slug']), ]; } $query = ($descriptor->model)::query(); if ($descriptor->tabFilterColumn && $activeTab) { $tabValue = TableRegistry::tabValue($section['slug'], $page['slug'], $activeTab['slug']); if ($tabValue !== null) { $query->where($descriptor->tabFilterColumn, $tabValue); } $tabColumn = $descriptor->tabFilterColumn; } foreach (TableRegistry::fixedFilters($section['slug'], $page['slug']) as $column => $value) { $query->where($column, $value); } $rows = $query->orderByDesc('id')->paginate(50)->withQueryString(); } } return [ 'section' => $section, 'page' => $page, 'tabs' => $tabs ?? collect(), 'activeTab' => $activeTab, 'content' => $content, 'descriptor' => $descriptor, 'rows' => $rows, 'upstreamError' => $upstreamError, 'tabColumn' => $tabColumn, 'tabValue' => $tabValue, 'edgeBanding' => $page ? $this->edgeBandingContent($page['slug']) : null, 'inventoryData' => $page ? $this->inventoryContent($page['slug']) : null, 'rawBoardData' => $page ? $this->rawBoardContent($page['slug']) : null, ]; } protected function resolveContent(string $page, ?string $tab): ?array { return match ($page) { 'marketing-account-bills' => $this->contentForMarketingAccountBills($tab), 'marketing-account-balance' => $this->contentForMarketingAccountBalance($tab), 'zhongcai-reconciliation' => $this->contentForZhongcaiReconciliation(), 'normal-products' => $this->contentForNormalProducts(), 'product-prices' => $this->contentForProductPrices(), 'board-deductions' => $this->contentForBoardDeductions(), 'supplier-payables-report' => $this->contentForSupplierPayablesReport(), default => null, }; } protected function contentForMarketingAccountBalance(?string $tab): array { $content = [ 'view' => 'banking.marketing_account_balances.partials.embedded-table', 'platform' => $tab, 'balances' => collect(), 'error' => null, 'pending' => false, ]; if (! in_array($tab, ['taobao-alipay', 'pinduoduo'], true)) { return $content; } $request = request(); $page = max(1, (int) $request->query('page', 1)); $pageSize = min(max((int) setting('default.list_limit', 25), 1), 100); $storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191); $month = trim((string) $request->query('month', '')); $month = preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : ''; $filters = [ 'store_name' => $storeName !== '' ? $storeName : null, 'month' => $month !== '' ? $month : null, ]; $isTaobao = $tab === 'taobao-alipay'; $repository = $isTaobao ? app(TmallTaobaoSpreadBillRepository::class) : app(PinduoduoSpreadBillRepository::class); $scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list'; $cache = $this->upstreamCaches->forSource('jiansun'); $usingMirror = $repository->usesBusinessMirror(); try { $resolver = fn () => $repository->paginateMonthlyBalances( $filters['store_name'], $filters['month'], $page, $pageSize, ); $result = $usingMirror ? $resolver() : $cache->remember( $scope, ['view' => 'monthly-balances', 'filters' => $filters, 'page' => $page, 'limit' => $pageSize], $resolver, ); } catch (UpstreamSnapshotPendingException $exception) { $content['pending'] = true; $result = ['rows' => [], 'total' => 0]; } catch (ServiceUnavailableHttpException $exception) { $content['error'] = $exception->getMessage(); $result = ['rows' => [], 'total' => 0]; } catch (Throwable $exception) { report($exception); $content['error'] = ($isTaobao ? '淘系' : '拼多多') . '账户余额数据暂时无法读取,请稍后重试。'; $result = ['rows' => [], 'total' => 0]; } try { $resolver = fn () => $repository->storeOptions(); $storeOptions = $usingMirror ? $resolver() : $cache->remember( $isTaobao ? 'taobao-alipay-bills.options' : 'pinduoduo-bills.options', [], $resolver, ); } catch (Throwable $exception) { report($exception); $storeOptions = []; } $content['balances'] = new LengthAwarePaginator( Collection::make($result['rows'] ?? []), (int) ($result['total'] ?? 0), $pageSize, $page, ['path' => $request->url(), 'query' => $request->query()], ); $content['filters'] = ['store_name' => $storeName, 'month' => $month]; $content['storeOptions'] = $storeOptions; return $content; } public function export(Request $request, string $section, string $page, string $tab) { abort_unless($section === 'billing-transactions', 404); $sectionConfig = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', $section); abort_if(empty($sectionConfig), 403, '当前账号没有该页面权限。'); $pageConfig = collect($sectionConfig['pages'] ?? [])->firstWhere('slug', $page); abort_if(empty($pageConfig), 403, '当前账号没有该页面权限。'); if ($page === 'marketing-account-bills') { return $this->exportMarketingAccountBills($request, $section, $page, $tab); } abort_unless($page === 'marketing-account-balance', 404); abort_unless(in_array($tab, ['taobao-alipay', 'pinduoduo'], true), 404); $storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191); $month = trim((string) $request->query('month', '')); $month = preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : ''; $filters = [ 'store_name' => $storeName !== '' ? $storeName : null, 'month' => $month !== '' ? $month : null, ]; $isTaobao = $tab === 'taobao-alipay'; $repository = $isTaobao ? app(TmallTaobaoSpreadBillRepository::class) : app(PinduoduoSpreadBillRepository::class); $scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list'; $redirectParameters = array_filter([ 'section' => $section, 'page' => $page, 'tab' => $tab, 'store_name' => $storeName, 'month' => $month, ], static fn (mixed $value): bool => $value !== ''); try { $resolver = fn () => $repository->paginateMonthlyBalances($filters['store_name'], $filters['month'], 1, 5000); $result = $repository->usesBusinessMirror() ? $resolver() : $this->upstreamCaches->forSource('jiansun')->remember( $scope, ['view' => 'monthly-balances', 'filters' => $filters, 'page' => 1, 'limit' => 5000, 'export' => true], $resolver, ); } catch (ServiceUnavailableHttpException) { return redirect()->route('business.pages.show', $redirectParameters) ->with('error', '首份共享快照正在准备,系统已登记刷新请求;请稍后刷新页面后再导出。'); } catch (Throwable $exception) { report($exception); return redirect()->route('business.pages.show', $redirectParameters) ->with('error', '余额数据导出暂时无法完成,请稍后重试。'); } $rows = $result['rows'] ?? []; return response()->streamDownload(function () use ($rows): void { $csv = fopen('php://output', 'w'); fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); fputcsv($csv, [ '日期', '店铺名称', '期初余额', '本期收入', '本期支出', '期末余额', '系统期末余额', '余额差异', '差异原因', '核对状态', '备注', ]); foreach ($rows as $row) { fputcsv($csv, [ $row['date'], $row['store_name'], $row['opening_balance'], $row['income_total'], $row['expense_total'], $row['closing_balance'], $row['system_closing_balance'], $row['balance_difference'], $row['difference_reason'], $row['verification_status'], $row['notes'], ]); } fclose($csv); }, ($isTaobao ? 'taobao' : 'pinduoduo') . '_account_balances_' . ($month !== '' ? $month : 'all') . '.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', ]); } private function exportMarketingAccountBills( Request $request, string $section, string $page, string $tab, ) { abort_unless($tab === 'pinduoduo', 404); $storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191); $fromDate = $this->dateFilter($request->query('from')); $toDate = $this->dateFilter($request->query('to')); $flowType = in_array($request->query('flow_type'), ['income', 'expense'], true) ? (string) $request->query('flow_type') : ''; $filters = [ 'store_name' => $storeName !== '' ? $storeName : null, 'from' => $fromDate, 'to' => $toDate, 'flow_type' => $flowType !== '' ? $flowType : null, ]; $redirectParameters = array_filter([ 'section' => $section, 'page' => $page, 'tab' => $tab, 'store_name' => $storeName, 'from' => $fromDate, 'to' => $toDate, 'flow_type' => $flowType, ], static fn (mixed $value): bool => $value !== '' && $value !== null); $repository = app(PinduoduoSpreadBillRepository::class); $csv = null; try { if (! $repository->usesBusinessMirror()) { return redirect()->route('business.pages.show', $redirectParameters) ->with('error', '拼多多账单共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。'); } $csv = fopen('php://temp/maxmemory:5242880', 'w+b'); if ($csv === false) { throw new \RuntimeException('无法创建拼多多账单导出临时文件。'); } fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); fputcsv($csv, ['店铺名称', '金额(元)', '余额(元)', '摘要', '收支类型', '资金类型', '费用时间']); foreach ($repository->exportRows( $filters['store_name'], $filters['from'], $filters['to'], $filters['flow_type'], ) as $row) { fputcsv($csv, [ $row['store_name'] ?? '', $row['amount'] ?? 0, $row['balance'] ?? 0, $row['summary'] ?? '', $row['income_expense_type'] ?? '', $row['fund_type'] ?? '', $row['cost_time'] ?? '', ]); } rewind($csv); } catch (Throwable $exception) { if (is_resource($csv)) { fclose($csv); } report($exception); return redirect()->route('business.pages.show', $redirectParameters) ->with('error', '拼多多账单导出暂时无法完成,请稍后重试。'); } $range = ($fromDate ?: 'all') . '_' . ($toDate ?: 'all'); return response()->streamDownload(function () use ($csv): void { fpassthru($csv); fclose($csv); }, 'pinduoduo_account_bills_' . $range . '.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', ]); } protected function contentForBoardDeductions(): array { $filters = [ 'stock_in_date' => $this->dateFilter(request('stock_in_date')), 'stock_out_date' => $this->dateFilter(request('stock_out_date')), 'furniture_factory' => mb_substr(trim((string) request('furniture_factory', '')), 0, 191), 'lamination_factory' => mb_substr(trim((string) request('lamination_factory', '')), 0, 191), 'deduction_type' => mb_substr(trim((string) request('deduction_type', '')), 0, 191), 'material_name' => mb_substr(trim((string) request('material_name', '')), 0, 191), 'claim_number' => mb_substr(trim((string) request('claim_number', '')), 0, 191), ]; $page = max((int) request('page', 1), 1); $limit = min(max((int) setting('default.list_limit', 25), 1), 100); $cache = $this->upstreamCaches->forSource('jiansun'); try { $result = $cache->remember( 'supplier-payables.board-deductions.list', ['filters' => $filters, 'page' => $page, 'limit' => $limit], fn () => $this->supplierPayableReports->boardDeductions($filters, $page, $limit), ); return [ 'boardDeductions' => $this->supplierPayablePaginator((array) ($result['rows'] ?? []), $limit, $page), 'boardDeductionFilters' => $filters, 'boardDeductionStatus' => $cache->responseHeaders(), 'boardDeductionError' => null, ]; } catch (ServiceUnavailableHttpException $exception) { return [ 'boardDeductions' => $this->supplierPayablePaginator([], $limit, $page), 'boardDeductionFilters' => $filters, 'boardDeductionStatus' => [], 'boardDeductionError' => $exception->getMessage(), ]; } catch (Throwable $exception) { report($exception); return [ 'boardDeductions' => $this->supplierPayablePaginator([], $limit, $page), 'boardDeductionFilters' => $filters, 'boardDeductionStatus' => [], 'boardDeductionError' => '板材扣款数据暂时无法读取,请稍后重试。', ]; } } protected function contentForSupplierPayablesReport(): array { $month = trim((string) request('month', '')); $filters = [ 'month' => preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : '', 'supplier' => mb_substr(trim((string) request('supplier', '')), 0, 191), 'checker' => mb_substr(trim((string) request('checker', '')), 0, 191), 'settlement_method' => mb_substr(trim((string) request('settlement_method', '')), 0, 191), ]; $page = max((int) request('page', 1), 1); $limit = min(max((int) setting('default.list_limit', 25), 1), 100); $cache = $this->upstreamCaches->forSource('jiansun'); try { $result = $cache->remember( 'supplier-payables.report.list', ['filters' => $filters, 'page' => $page, 'limit' => $limit], fn () => $this->supplierPayableReports->supplierPayables($filters, $page, $limit), ); return [ 'supplierPayables' => $this->supplierPayablePaginator((array) ($result['rows'] ?? []), $limit, $page), 'supplierPayableFilters' => $filters, 'supplierPayableStatus' => $cache->responseHeaders(), 'supplierPayableError' => null, ]; } catch (ServiceUnavailableHttpException $exception) { return [ 'supplierPayables' => $this->supplierPayablePaginator([], $limit, $page), 'supplierPayableFilters' => $filters, 'supplierPayableStatus' => [], 'supplierPayableError' => $exception->getMessage(), ]; } catch (Throwable $exception) { report($exception); return [ 'supplierPayables' => $this->supplierPayablePaginator([], $limit, $page), 'supplierPayableFilters' => $filters, 'supplierPayableStatus' => [], 'supplierPayableError' => '供应商应付数据暂时无法读取,请稍后重试。', ]; } } private function supplierPayablePaginator(array $rows, int $limit, int $page): Paginator { return new Paginator( Collection::make($rows)->map(static fn (array $row): object => (object) $row), $limit, $page, [ 'path' => request()->url(), 'query' => request()->except('page'), ], ); } protected function contentForNormalProducts(): array { $filters = [ 'i_id' => mb_substr(trim((string) request('i_id', '')), 0, 50), 'sku_id' => mb_substr(trim((string) request('sku_id', '')), 0, 50), 'keyword' => mb_substr(trim((string) request('keyword', '')), 0, 100), 'brand' => mb_substr(trim((string) request('brand', '')), 0, 100), 'category' => mb_substr(trim((string) request('category', '')), 0, 100), 'supplier_name' => mb_substr(trim((string) request('supplier_name', '')), 0, 191), 'enabled' => in_array((string) request('enabled', ''), ['-1', '0', '1'], true) ? (string) request('enabled') : '', 'stock_disabled' => in_array((string) request('stock_disabled', ''), ['0', '1'], true) ? (string) request('stock_disabled') : '', 'sale_price_min' => $this->decimalFilter(request('sale_price_min')), 'sale_price_max' => $this->decimalFilter(request('sale_price_max')), ]; if ($filters['sale_price_min'] !== null && $filters['sale_price_max'] !== null && (float) $filters['sale_price_min'] > (float) $filters['sale_price_max']) { [$filters['sale_price_min'], $filters['sale_price_max']] = [ $filters['sale_price_max'], $filters['sale_price_min'], ]; } $page = max((int) request('page', 1), 1); $limit = min(max((int) setting('default.list_limit', 25), 1), 50); try { if (! $this->productCatalog->available()) { return $this->emptyProductCatalogContent( $filters, $limit, $page, '共享业务镜像尚未建立,普通商品资料暂时无法读取。', null, ['code' => 'unavailable', 'lag_minutes' => null], ); } $state = $this->productCatalog->state(); $syncStatus = $this->productCatalog->syncStatus($state); if (! $this->productCatalog->ready()) { return $this->emptyProductCatalogContent( $filters, $limit, $page, '普通商品资料正在执行首次全量同步,完成前不会把未同步数据误判为无数据。', $state, $syncStatus, ); } $result = $this->productCatalog->paginate($filters, $page, $limit); $paginator = new LengthAwarePaginator( $result['data'], $result['total'], $limit, $page, [ 'path' => request()->url(), 'query' => request()->except('page'), ], ); return [ 'productCatalogRows' => $paginator, 'productCatalogFilters' => $filters, 'productCatalogError' => null, 'productCatalogState' => $state, 'productCatalogSyncStatus' => $syncStatus, 'productCatalogProductPriceSource' => ProductCatalogMirrorRepository::PRODUCT_SOURCE_LABEL, 'productCatalogPurchasePriceSource' => ProductCatalogMirrorRepository::PURCHASE_SOURCE_LABEL, 'productCatalogPurchasePriceSyncStatus' => $result['purchase_price_sync_status'] ?? [ 'code' => 'unavailable', 'initialized' => false, 'last_succeeded_at' => null, ], ]; } catch (Throwable $exception) { report($exception); return $this->emptyProductCatalogContent( $filters, $limit, $page, '共享业务数据暂时无法读取,请稍后重试。', null, ['code' => 'unavailable', 'lag_minutes' => null], ); } } private function emptyProductCatalogContent( array $filters, int $limit, int $page, string $error, ?array $state, array $syncStatus, ): array { return [ 'productCatalogRows' => new LengthAwarePaginator( [], 0, $limit, $page, [ 'path' => request()->url(), 'query' => request()->except('page'), ], ), 'productCatalogFilters' => $filters, 'productCatalogError' => $error, 'productCatalogState' => $state, 'productCatalogSyncStatus' => $syncStatus, 'productCatalogProductPriceSource' => ProductCatalogMirrorRepository::PRODUCT_SOURCE_LABEL, 'productCatalogPurchasePriceSource' => ProductCatalogMirrorRepository::PURCHASE_SOURCE_LABEL, 'productCatalogPurchasePriceSyncStatus' => [ 'code' => 'unavailable', 'initialized' => false, 'last_succeeded_at' => null, ], ]; } protected function contentForProductPrices(): array { $filters = [ 'barcode' => mb_substr(trim((string) request('barcode', '')), 0, 191), 'completed_from' => $this->dateFilter(request('completed_from')), 'completed_to' => $this->dateFilter(request('completed_to')), ]; $page = max((int) request('page', 1), 1); $limit = min(max((int) setting('default.list_limit', 25), 1), 100); try { if (! $this->productPrices->ready()) { return $this->emptyProductPriceContent( $filters, $limit, $page, '共享业务库尚未完成建库,请等待负责人完成初始化。' ); } if (! $this->productPrices->initialized()) { return $this->emptyProductPriceContent( $filters, $limit, $page, '商品价格正在执行首次全量同步,完成前不会把未同步数据误判为无数据。' ); } $paginator = $this->productPrices->paginate($filters, $page, $limit); $paginator->appends(request()->except('page')); return [ 'productPrices' => $paginator, 'productPriceFilters' => $filters, 'productPriceError' => null, ]; } catch (Throwable $exception) { report($exception); return $this->emptyProductPriceContent( $filters, $limit, $page, '共享业务数据暂时无法读取,请稍后重试。' ); } } private function emptyProductPriceContent(array $filters, int $limit, int $page, string $error): array { return [ 'productPrices' => new LengthAwarePaginator( [], 0, $limit, $page, [ 'path' => request()->url(), 'query' => request()->except('page'), ], ), 'productPriceFilters' => $filters, 'productPriceError' => $error, ]; } /** * 封边条页面只读取上游快照;未启用或无快照时将错误交给页面显示, * 避免工作台因上游暂不可用直接返回 500。 */ protected function edgeBandingContent(string $page): ?array { $scope = match ($page) { 'edge-banding-prices' => 'edge-banding.prices', 'details' => 'edge-banding.details', 'edge-banding-payables' => 'edge-banding.payables', default => null, }; if ($scope === null) { return null; } $filters = match ($page) { 'edge-banding-prices' => $this->edgeBandingFilters([ 'raw_name', 'raw_code', 'production_code', 'supplier', ]), 'details' => $this->edgeBandingFilters([ 'date', 'production_code', 'raw_name', 'return_flag', 'outbound_supplier', 'arrival_supplier', 'outbound_no', ]), 'edge-banding-payables' => $this->edgeBandingFilters(['period', 'supplier']), default => [], }; if ($page === 'edge-banding-payables' && ! isset($filters['period'])) { $filters['period'] = '2026-06'; } $pageNumber = max((int) request('data_page', 1), 1); $limit = min(max((int) request('data_limit', 25), 1), 100); $cache = $this->upstreamCaches->forSource(self::EDGE_BANDING_SOURCE); try { $result = $cache->remember( $scope, ['filters' => $filters, 'page' => $pageNumber, 'limit' => $limit], fn () => match ($scope) { 'edge-banding.prices' => $this->edgeBanding->prices($filters, $pageNumber, $limit), 'edge-banding.details' => $this->edgeBanding->details($filters, $pageNumber, $limit), 'edge-banding.payables' => $this->edgeBanding->payables($filters, $pageNumber, $limit), }, ); return [ 'filters' => $filters, 'result' => $result, 'page' => $pageNumber, 'limit' => $limit, 'last_page' => max((int) ceil(max((int) ($result['total'] ?? 0), 0) / $limit), 1), 'status' => $cache->responseHeaders(), 'error' => null, ]; } catch (ServiceUnavailableHttpException $exception) { return [ 'filters' => $filters, 'result' => ['data' => [], 'total' => 0], 'page' => $pageNumber, 'limit' => $limit, 'last_page' => 1, 'status' => [], 'error' => $exception->getMessage(), ]; } catch (Throwable $exception) { report($exception); return [ 'filters' => $filters, 'result' => ['data' => [], 'total' => 0], 'page' => $pageNumber, 'limit' => $limit, 'last_page' => 1, 'status' => [], 'error' => '封边条数据暂时无法读取,请稍后重试。', ]; } } private function edgeBandingFilters(array $fields): array { $filters = []; foreach ($fields as $field) { $value = trim((string) request($field, '')); if ($value !== '') { $filters[$field] = $value; } } return $filters; } /** * 素板价格与对账页面使用 JianSun 只读查询,并保留共享快照链路。 */ protected function rawBoardContent(string $page): ?array { $scope = match ($page) { 'board-prices' => 'raw-board.prices', 'board-reconciliation' => 'raw-board.reconciliations', default => null, }; if ($scope === null) { return null; } $filters = $scope === 'raw-board.prices' ? $this->rawBoardFilters(['raw_name', 'raw_code', 'production_code', 'supplier']) : $this->rawBoardReconciliationFilters(); $pageNumber = max((int) request('data_page', 1), 1); $limit = min(max((int) request('data_limit', 25), 1), 100); try { $cache = $this->upstreamCaches->forSource(self::RAW_BOARD_SOURCE); $result = $cache->remember( $scope, ['filters' => $filters, 'page' => $pageNumber, 'limit' => $limit], fn () => $scope === 'raw-board.prices' ? $this->rawBoardPayables->prices($filters, $pageNumber, $limit) : $this->rawBoardPayables->reconciliations($filters, $pageNumber, $limit), ); return [ 'filters' => $filters, 'result' => $result, 'page' => $pageNumber, 'limit' => $limit, 'last_page' => max((int) ceil(max((int) ($result['total'] ?? 0), 0) / $limit), 1), 'status' => $cache->responseHeaders(), 'error' => null, ]; } catch (ServiceUnavailableHttpException $exception) { return $this->emptyRawBoardContent($filters, $pageNumber, $limit, $exception->getMessage()); } catch (Throwable $exception) { report($exception); return $this->emptyRawBoardContent( $filters, $pageNumber, $limit, '素板应付数据暂时无法读取,请稍后重试。', ); } } private function rawBoardFilters(array $fields): array { $filters = []; foreach ($fields as $field) { $value = mb_substr(trim((string) request($field, '')), 0, 100); if ($value !== '') { $filters[$field] = $value; } } return $filters; } private function rawBoardReconciliationFilters(): array { $filters = $this->rawBoardFilters([ 'production_code', 'raw_name', 'outbound_supplier', 'arrival_supplier', 'outbound_no', ]); foreach (['date_from', 'date_to'] as $field) { $value = $this->dateFilter(request($field)); if ($value !== '') { $filters[$field] = $value; } } $returnFlag = trim((string) request('return_flag', '')); if (in_array($returnFlag, ['normal', 'return'], true)) { $filters['return_flag'] = $returnFlag; } return $filters; } private function emptyRawBoardContent(array $filters, int $page, int $limit, string $error): array { return [ 'filters' => $filters, 'result' => ['data' => [], 'total' => 0, 'totals' => []], 'page' => $page, 'limit' => $limit, 'last_page' => 1, 'status' => [], 'error' => $error, ]; } /** * 板材库存页面使用 JianSun 最新素板快照,并保留生产共享快照链路。 */ protected function inventoryContent(string $page): ?array { if ($page !== 'board-inventory-details') { return null; } $filters = $this->inventoryFilters(); $pageNumber = max((int) request('data_page', 1), 1); $limit = min(max((int) request('data_limit', 25), 1), 100); try { $cache = $this->upstreamCaches->forSource(self::INVENTORY_SOURCE); $result = $cache->remember( 'inventory.board-details', ['filters' => $filters, 'page' => $pageNumber, 'limit' => $limit], fn () => $this->inventory->boardInventoryDetails($filters, $pageNumber, $limit), ); return [ 'filters' => $filters, 'result' => $result, 'page' => $pageNumber, 'limit' => $limit, 'last_page' => max((int) ceil(max((int) ($result['total'] ?? 0), 0) / $limit), 1), 'status' => $cache->responseHeaders(), 'error' => null, ]; } catch (ServiceUnavailableHttpException $exception) { return $this->emptyInventoryContent($filters, $pageNumber, $limit, $exception->getMessage()); } catch (Throwable $exception) { report($exception); return $this->emptyInventoryContent( $filters, $pageNumber, $limit, '板材库存数据暂时无法读取,请稍后重试。', ); } } private function inventoryFilters(): array { $filters = []; foreach (['manufacturer', 'specification', 'matching_value'] as $field) { $value = mb_substr(trim((string) request($field, '')), 0, 100); if ($value !== '') { $filters[$field] = $value; } } foreach (['date_from', 'date_to'] as $field) { $value = $this->dateFilter(request($field)); if ($value !== '') { $filters[$field] = $value; } } return $filters; } private function emptyInventoryContent(array $filters, int $page, int $limit, string $error): array { return [ 'filters' => $filters, 'result' => ['data' => [], 'total' => 0, 'totals' => []], 'page' => $page, 'limit' => $limit, 'last_page' => 1, 'status' => [], 'error' => $error, ]; } /** * 种菜应收对账 - 加载对账数据(21字段) */ protected function contentForZhongcaiReconciliation(): array { return [ 'view' => 'store_receivables.zhongcai_reconciliations.partials.embedded-table', 'records' => StoreZhongcaiReconciliation::orderBy('bill_date', 'desc') ->orderBy('id', 'desc') ->paginate(setting('default.list_limit', 25)), ]; } protected function contentForMarketingAccountBills(?string $tab): ?array { if (! in_array($tab, self::MARKETING_PLATFORMS, true)) { return null; } // Alibaba 页面沿用历史 taobao-wechat 路由,数据来自 JianSun.dbo.alibaba_bill。 if ($tab === 'taobao-wechat') { $request = request(); $shopName = mb_substr(trim((string) $request->query('shop_name')), 0, 191); $productType = mb_substr(trim((string) $request->query('product_type')), 0, 191); $fromDate = $this->dateFilter($request->query('from')); $toDate = $this->dateFilter($request->query('to')); $page = max(1, (int) $request->query('page', 1)); $pageSize = min(max((int) setting('default.list_limit', 25), 1), 100); $filters = [ 'shop_name' => $shopName !== '' ? $shopName : null, 'product_type' => $productType !== '' ? $productType : null, 'from' => $fromDate !== '' ? $fromDate : null, 'to' => $toDate !== '' ? $toDate : null, ]; $repository = app(AlibabaBillRepository::class); $cache = $this->upstreamCaches->forSource('jiansun'); $usingMirror = $repository->usesBusinessMirror(); try { $listResolver = fn () => $repository->paginate( $filters['shop_name'], $filters['product_type'], $filters['from'], $filters['to'], $page, $pageSize, ); $summaryResolver = fn () => $repository->summary( $filters['shop_name'], $filters['product_type'], $filters['from'], $filters['to'], ); $optionsResolver = fn () => $repository->filterOptions(); $result = $usingMirror ? $listResolver() : $cache->remember( 'marketing-account-bills.alibaba.list', ['filters' => $filters, 'page' => $page, 'limit' => $pageSize], $listResolver, ); $summary = $usingMirror ? $summaryResolver() : $cache->remember( 'marketing-account-bills.alibaba.summary', ['filters' => $filters], $summaryResolver, ); $options = $usingMirror ? $optionsResolver() : $cache->remember( 'marketing-account-bills.alibaba.options', [], $optionsResolver, ); } catch (ServiceUnavailableHttpException $exception) { return [ 'view' => 'banking.marketing_alibaba_bills.partials.embedded-table', 'bills' => new LengthAwarePaginator([], 0, $pageSize, $page, [ 'path' => $request->url(), 'query' => $request->query(), ]), 'filters' => $filters, 'summary' => ['income_total' => 0, 'expense_total' => 0, 'net' => 0, 'count' => 0], 'options' => ['shops' => [], 'product_types' => []], 'error' => $exception->getMessage(), ]; } catch (Throwable $exception) { report($exception); return [ 'view' => 'banking.marketing_alibaba_bills.partials.embedded-table', 'bills' => new LengthAwarePaginator([], 0, $pageSize, $page, [ 'path' => $request->url(), 'query' => $request->query(), ]), 'filters' => $filters, 'summary' => ['income_total' => 0, 'expense_total' => 0, 'net' => 0, 'count' => 0], 'options' => ['shops' => [], 'product_types' => []], 'error' => 'Alibaba 账单数据暂时无法读取,请稍后重试。', ]; } $bills = new LengthAwarePaginator( Collection::make($result['rows'] ?? []), (int) ($result['total'] ?? 0), $pageSize, $page, ['path' => $request->url(), 'query' => $request->query()], ); return [ 'view' => 'banking.marketing_alibaba_bills.partials.embedded-table', 'bills' => $bills, 'filters' => $filters, 'summary' => $summary, 'options' => $options, 'error' => null, ]; } // 淘系支付宝 - 从上游 JianSun.tmall_spread_bills 读取并按表1字段展示(9列) if ($tab === 'taobao-alipay') { $request = request(); $storeName = mb_substr(trim((string) $request->query('store_name')), 0, 191); $fromDate = $this->dateFilter($request->query('from')); $toDate = $this->dateFilter($request->query('to')); $flowType = trim((string) $request->query('flow_type')); $flowType = in_array($flowType, ['income', 'expense'], true) ? $flowType : ''; $page = max(1, (int) $request->query('page', 1)); $pageSize = min(max((int) setting('default.list_limit', 25), 1), 100); $repository = app(TmallTaobaoSpreadBillRepository::class); $cache = $this->upstreamCaches->forSource('jiansun'); $filters = [ 'store_name' => $storeName !== '' ? $storeName : null, 'from' => $fromDate !== '' ? $fromDate : null, 'to' => $toDate !== '' ? $toDate : null, 'flow_type' => $flowType !== '' ? $flowType : null, ]; $listResolver = fn () => $repository->paginate( $filters['store_name'], $filters['from'], $filters['to'], $filters['flow_type'], $page, $pageSize ); $summaryResolver = fn () => $repository->summary( $filters['store_name'], $filters['from'], $filters['to'], $filters['flow_type'] ); $optionsResolver = fn () => $repository->storeOptions(); $usingMirror = $repository->usesBusinessMirror(); $result = $usingMirror ? $listResolver() : $cache->remember( 'taobao-alipay-bills.list', ['filters' => $filters, 'page' => $page, 'limit' => $pageSize], $listResolver, ); $summary = $usingMirror ? $summaryResolver() : $cache->remember( 'taobao-alipay-bills.summary', ['filters' => $filters], $summaryResolver, ); $storeOptions = $usingMirror ? $optionsResolver() : $cache->remember( 'taobao-alipay-bills.options', [], $optionsResolver, ); $bills = new LengthAwarePaginator( Collection::make($result['rows']), $result['total'], $pageSize, $page, [ 'path' => $request->url(), 'query' => $request->query(), ] ); return [ 'view' => 'banking.marketing_taobao_alipay_bills.partials.embedded-table', 'bills' => $bills, 'filters' => [ 'store_name' => $storeName, 'from' => $fromDate, 'to' => $toDate, 'flow_type' => $flowType, 'store_options' => $storeOptions, ], 'summary' => $summary, ]; } // 拼多多 - 从上游 JianSun.pdd_spread_bills 读取并按表1字段展示(7列) // 表1列:店铺名称 | 金额 | 余额 | 摘要 | 收支类型 | 资金类型 | 费用时间 if ($tab === 'pinduoduo') { $request = request(); $storeName = mb_substr(trim((string) $request->query('store_name')), 0, 191); $fromDate = $this->dateFilter($request->query('from')); $toDate = $this->dateFilter($request->query('to')); $flowType = trim((string) $request->query('flow_type')); $flowType = in_array($flowType, ['income', 'expense'], true) ? $flowType : ''; $page = max(1, (int) $request->query('page', 1)); $pageSize = min(max((int) setting('default.list_limit', 25), 1), 100); $repository = app(PinduoduoSpreadBillRepository::class); $cache = $this->upstreamCaches->forSource('jiansun'); $filters = [ 'store_name' => $storeName !== '' ? $storeName : null, 'from' => $fromDate !== '' ? $fromDate : null, 'to' => $toDate !== '' ? $toDate : null, 'flow_type' => $flowType !== '' ? $flowType : null, ]; // 页面只读取共享查询快照;冷查询由统一限流和熔断链路访问上游。 $listResolver = fn () => $repository->paginate( $filters['store_name'], $filters['from'], $filters['to'], $filters['flow_type'], $page, $pageSize ); $summaryResolver = fn () => $repository->summary( $filters['store_name'], $filters['from'], $filters['to'], $filters['flow_type'] ); $optionsResolver = fn () => $repository->storeOptions(); $usingMirror = $repository->usesBusinessMirror(); $result = $usingMirror ? $listResolver() : $cache->remember( 'pinduoduo-bills.list', ['filters' => $filters, 'page' => $page, 'limit' => $pageSize], $listResolver, ); $summary = $usingMirror ? $summaryResolver() : $cache->remember( 'pinduoduo-bills.summary', ['filters' => $filters], $summaryResolver, ); $storeOptions = $usingMirror ? $optionsResolver() : $cache->remember( 'pinduoduo-bills.options', [], $optionsResolver, ); // 包装成 Laravel 分页器,便于 Blade 用 $bills->links() $bills = new LengthAwarePaginator( Collection::make($result['rows']), $result['total'], $pageSize, $page, [ 'path' => $request->url(), 'query' => $request->query(), ] ); return [ 'view' => 'banking.marketing_pinduoduo_bills.partials.embedded-table', 'bills' => $bills, 'filters' => [ 'store_name' => $storeName, 'from' => $fromDate, 'to' => $toDate, 'flow_type' => $flowType, 'store_options' => $storeOptions, ], 'summary' => $summary, ]; } // 京东钱包 - 按附件表1分别展示充值账单和消耗账单,数据来自 JianSun 上游只读表。 if ($tab === 'jd-wallet') { $request = request(); $ledger = $request->query('jd_ledger') === 'consumption' ? 'consumption' : 'recharge'; $storeName = mb_substr(trim((string) $request->query('store_name')), 0, 191); $fromDate = $this->dateFilter($request->query('from')); $toDate = $this->dateFilter($request->query('to')); $page = max(1, (int) $request->query('page', 1)); $pageSize = min(max((int) setting('default.list_limit', 25), 1), 100); $filters = [ 'store_name' => $storeName !== '' ? $storeName : null, 'from' => $fromDate !== '' ? $fromDate : null, 'to' => $toDate !== '' ? $toDate : null, ]; $repository = app(JdWalletBillRepository::class); $cache = $this->upstreamCaches->forSource('jiansun'); $scopePrefix = 'jd-wallet-bills.' . $ledger; $errors = []; $usingMirror = $repository->usesBusinessMirror($ledger); $remember = function (string $scope, array $parameters, callable $resolver, mixed $fallback) use ($cache, $usingMirror, &$errors): mixed { try { return $usingMirror ? $resolver() : $cache->remember($scope, $parameters, $resolver); } catch (ServiceUnavailableHttpException $exception) { $errors[] = $exception->getMessage(); } catch (Throwable $exception) { report($exception); $errors[] = '京东账单数据暂时无法读取,请稍后重试。'; } return $fallback; }; // 三项分别读取,确保生产冷启动时一次请求就能登记列表、汇总和选项, // 同时保留已经命中的共享快照,不因另一项暂缺而清空整页。 $result = $remember( $scopePrefix . '.list', ['filters' => $filters, 'page' => $page, 'limit' => $pageSize], fn () => $ledger === 'consumption' ? $repository->paginateConsumption($filters['store_name'], $filters['from'], $filters['to'], $page, $pageSize) : $repository->paginateRecharge($filters['store_name'], $filters['from'], $filters['to'], $page, $pageSize), ['rows' => [], 'total' => 0], ); $summary = $remember( $scopePrefix . '.summary', ['filters' => $filters], fn () => $ledger === 'consumption' ? $repository->summaryConsumption($filters['store_name'], $filters['from'], $filters['to']) : $repository->summaryRecharge($filters['store_name'], $filters['from'], $filters['to']), ['amount_total' => 0, 'balance_total' => 0, 'count' => 0], ); try { $optionsResolver = fn () => $repository->shopOptions(); $shopOptions = $repository->usesBusinessMirrorForOptions() ? $optionsResolver() : $cache->remember('jd-wallet-bills.options', [], $optionsResolver); } catch (ServiceUnavailableHttpException $exception) { $errors[] = $exception->getMessage(); $shopOptions = []; } catch (Throwable $exception) { report($exception); $errors[] = '京东账单数据暂时无法读取,请稍后重试。'; $shopOptions = []; } return [ 'view' => 'banking.marketing_jd_wallet_bills.partials.embedded-table', 'ledger' => $ledger, 'bills' => new LengthAwarePaginator( Collection::make($result['rows'] ?? []), (int) ($result['total'] ?? 0), $pageSize, $page, ['path' => $request->url(), 'query' => $request->query()], ), 'filters' => $filters, 'summary' => $summary, 'shopOptions' => $shopOptions, 'error' => $errors === [] ? null : implode(' ', array_values(array_unique(array_filter($errors)))), ]; } return [ 'view' => 'banking.marketing_account_bills.partials.embedded-table', 'platform' => $tab, 'accounts' => MarketingAccountBill::platform($tab) ->orderBy('bill_date', 'desc') ->orderBy('id', 'desc') ->paginate(setting('default.list_limit', 25)), ]; } public function store(Request $request, string $section, string $page, ?string $tab = null) { $descriptor = TableRegistry::describe($section, $page, $tab); abort_if(! $descriptor, 404); $data = $request->validate($this->validationRules($descriptor)); if ($descriptor->tabFilterColumn && $tab && ($tabValue = TableRegistry::tabValue($section, $page, $tab))) { $data[$descriptor->tabFilterColumn] = $tabValue; } foreach (TableRegistry::fixedFilters($section, $page) as $column => $value) { $data[$column] = $value; } ($descriptor->model)::create($data); return $this->redirectBack($section, $page, $tab); } public function import( Request $request, BusinessTableImporter $importer, string $section, string $page, ?string $tab = null, ) { $descriptor = TableRegistry::describe($section, $page, $tab); abort_if(! $descriptor, 404); $request->validate([ 'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240'], ]); $result = $importer->import($request->file('import'), $descriptor, $section, $page, $tab); return $this->redirectBack($section, $page, $tab)->with( 'success', "导入完成:新增 {$result['created']} 条,更新 {$result['updated']} 条。", ); } public function update(Request $request, string $section, string $page, $id, ?string $tab = null) { $descriptor = TableRegistry::describe($section, $page, $tab); abort_if(! $descriptor, 404); $row = ($descriptor->model)::findOrFail($id); $row->update($request->validate($this->validationRules($descriptor))); return $this->redirectBack($section, $page, $tab); } public function destroy(string $section, string $page, $id, ?string $tab = null) { $descriptor = TableRegistry::describe($section, $page, $tab); abort_if(! $descriptor, 404); ($descriptor->model)::findOrFail($id)->delete(); return $this->redirectBack($section, $page, $tab); } protected function validationRules($descriptor): array { $rules = []; foreach ($descriptor->fields as $field) { $rules[$field['name']] = match ($field['type']) { 'number', 'currency' => ['nullable', 'numeric'], 'boolean' => ['nullable', 'boolean'], 'date' => ['nullable', 'date'], 'select' => ['nullable', 'string', 'max:64'], default => ['nullable', 'string', 'max:255'], }; } return $rules; } protected function redirectToPage(array $section, array $page) { $parameters = [ 'section' => $section['slug'], 'page' => $page['slug'], ]; if (! empty($page['tabs'])) { $parameters['tab'] = $page['tabs'][0]['slug']; } return redirect()->route('business.pages.show', $parameters); } private function dateFilter(mixed $value): string { $date = trim((string) $value); if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) { return ''; } [$year, $month, $day] = array_map('intval', explode('-', $date)); return checkdate($month, $day, $year) ? $date : ''; } private function decimalFilter(mixed $value): ?string { $value = trim((string) $value); if ($value === '' || ! is_numeric($value)) { return null; } return (string) (float) $value; } protected function redirectBack(string $section, string $page, ?string $tab) { $parameters = [ 'company_id' => company_id() ?: 1, 'section' => $section, 'page' => $page, ]; if ($tab) { $parameters['tab'] = $tab; } return redirect()->route('business.pages.show', $parameters)->with('success', true); } }