middleware('auth'); $this->middleware('company.identify'); } /** * 支持的营销账户平台列表 */ protected const MARKETING_PLATFORMS = [ 'taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet', 'douyin', 'dewu', 'vipshop', 'jd-promotion', 'douyin-promotion', 'dewu-promotion', 'vip-promotion', ]; /** @var list 推广账户流水已合并到营销账户账单页的页签 */ protected const PROMOTION_MARKETING_TABS = [ 'jd-promotion', 'douyin-promotion', 'dewu-promotion', 'vip-promotion', ]; protected const ACCOUNT_BALANCE_SECTION = 'account-balance-data'; protected const SMALL_PAYMENT_SECTION = 'financial-operations'; protected const ACCOUNT_BALANCE_PAGES = [ 'bank-balance', 'store-receiving-balance', 'marketing-account-balance', 'deposit-account-balance', ]; public function home() { $section = collect(SidebarPermissions::navigationFor(user()))->first(); if (empty($section)) { return view('business.access-pending'); } $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) { if ($section === 'refunds' && $page === 'small-payment-work-orders') { $url = route('business.pages.show', [ 'section' => self::SMALL_PAYMENT_SECTION, 'page' => $page, ]); $query = request()->getQueryString(); return redirect()->to($url . ($query === null ? '' : '?' . $query)); } if ($section === 'billing-transactions' && in_array($page, self::ACCOUNT_BALANCE_PAGES, true)) { $url = route('business.pages.show', array_filter([ 'section' => self::ACCOUNT_BALANCE_SECTION, 'page' => $page, 'tab' => $tab, ], static fn (mixed $value): bool => $value !== null)); $query = request()->getQueryString(); return redirect()->to($url . ($query === null ? '' : '?' . $query)); } $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) { if ($section === 'courier-payables') { return view('business.pages.show', $this->viewData($sectionConfig)); } 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()) { // Old bookmarked tab URLs should land on the now-empty parent // directory while the receiving-account pages are being rebuilt. if ($tab !== null) { return redirect()->route('business.pages.show', [ 'section' => $sectionConfig['slug'], 'page' => $pageConfig['slug'], ]); } if ($section === 'courier-payables' && $page === 'freight-reconciliation' && request()->boolean('courier_summary')) { try { $summary = $this->courierFreightReconciliations->requestSummary($this->freightReconciliationFilters()); $response = response()->json(['data' => $summary], $summary === null ? 202 : 200, [ 'Cache-Control' => 'private, no-store', 'X-Accel-Buffering' => 'no', ]); // Explicit framing lets clients finish reading before the // application termination callback computes the aggregate. return $response->header('Content-Length', strlen($response->getContent())); } catch (Throwable $exception) { report($exception); return response()->json(['message' => '完整汇总暂时无法计算,明细仍可正常查看。'], 503, ['Cache-Control' => 'private, no-store']); } } 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), )); } public function installationDetails(string $guid) { $section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations'); abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'installation-details'), 403, '当前账号没有该页面权限。'); $payload = $this->installationDetails->details($guid); if (! $payload['ready']) { return response()->json(['message' => '安装明细镜像尚未完成首次同步'], 503); } return response()->json(['data' => $payload['data'], 'has_more' => $payload['has_more'] ?? false]); } public function exportInstallationDetails(Request $request): StreamedResponse { $section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations'); abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'installation-details'), 403, '当前账号没有该页面权限。'); $filters = InstallationDetailMirrorRepository::requestFilters(); $firstPage = $this->installationDetails->paginate(1, 100, $filters); abort_if(! $firstPage['ready'], 503, '安装明细镜像尚未完成首次同步'); $labels = InstallationDetail::columnLabels(); return response()->streamDownload(function () use ($filters, $firstPage, $labels): void { $csv = fopen('php://output', 'w'); if ($csv === false) { throw new \RuntimeException('无法创建安装明细导出流。'); } fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); fputcsv($csv, array_values($labels)); $page = 1; $result = $firstPage; do { foreach ($result['data'] as $row) { fputcsv($csv, array_map( fn (string $name): string => $this->csvText($row[$name] ?? ''), array_keys($labels), )); } $page++; $hasMore = ($page - 1) * 100 < $result['total']; if ($hasMore) { $result = $this->installationDetails->paginate($page, 100, $filters); } } while ($hasMore); fclose($csv); }, '安装明细_' . now()->format('Ymd_His') . '.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', ]); } public function exportBillOffset(Request $request): StreamedResponse { $section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'order-data'); abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'bill-offset'), 403, '当前账号没有该页面权限。'); $repository = app(BillOffsetPageData::class); $filters = $request->query(); $result = $repository->data($filters, 1, 100); abort_if($result['billOffsetError'] !== null, 503, $result['billOffsetError']); $filters = $result['billOffsetFilters']; return response()->streamDownload(function () use ($repository, $filters, $result): void { $csv = fopen('php://output', 'w'); if ($csv === false) { throw new \RuntimeException('无法创建账单冲减导出流。'); } fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); $labels = [ 'internal_order_no' => '内部订单号', 'online_order_no' => '线上订单号', 'shop_name' => '店铺名称', 'shop_code' => '店铺编码', 'product_code' => '商品编码', 'style_code' => '款式编码(参考)', 'shop_product_code' => '店铺商品编码', 'shop_style_code' => '店铺款式编码', 'account_500112' => '500112冲减收入', ]; fputcsv($csv, array_values($labels)); $current = $result; $page = 1; do { foreach ($current['billOffsetRows'] as $row) { fputcsv($csv, array_map( function (string $field) use ($row): string { $value = data_get($row, $field); if ($field === 'account_500112') { return $value === null || $value === '' ? '' : number_format((float) $value, 2, '.', ''); } return $field === 'shop_name' ? $this->csvText($value) : $this->csvIdentifier($value); }, array_keys($labels), )); } if (! $current['billOffsetRows']->hasMorePages()) break; $current = $repository->dataForNormalizedFilters($filters, ++$page, 100); if ($current['billOffsetError'] !== null) { throw new \RuntimeException('账单冲减导出未完成,请重试。'); } } while (true); fclose($csv); }, '账单冲减_' . now()->format('Ymd_His') . '.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', ]); } 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; $agreementCourierNames = $this->agreementCourierNames($page['slug'] ?? null); $agreementCourierAccounts = $this->agreementCourierAccounts($page['slug'] ?? null, $agreementCourierNames); if (($section['slug'] ?? null) === 'product-inventory' && ($page['slug'] ?? null) === 'inventory-report') { [$rows, $upstreamError] = $this->inventoryReportRows(); } 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']), 'agreementTasks' => $this->agreementTasks($page['slug']), 'agreementQuoteSignatures' => $this->agreementQuoteSignatures($page['slug']), 'agreementSubjects' => $this->agreementSubjects($page['slug']), 'agreementCourierNames' => $agreementCourierNames, 'agreementCourierAccounts' => $agreementCourierAccounts, 'agreementQuoteVersions' => $this->agreementQuoteVersions($page['slug']), ]; } if (($section['slug'] ?? null) === 'product-inventory' && ($page['slug'] ?? null) === 'inventory-report') { // inventoryReportRows() 已从聚水潭共享镜像按全仓口径取数。 // 这里必须保留该结果,不能再被目录表的旧演示数据覆盖。 } elseif ($this->isStoreReceivingAccountPage($section['slug'], $page['slug'])) { try { app(\App\Business\StoreReceivingAccountSynchronizer::class)->sync((int) (company_id() ?: 1)); } catch (Throwable $exception) { report($exception); $upstreamError = '店铺基础信息暂时无法同步,请稍后重试。'; } $rows = $this->storeReceivingAccountRows($activeTab['slug'] ?? null); $tabColumn = 'store_platform'; } else { $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, 'agreementTasks' => $this->agreementTasks($page['slug'] ?? null), 'agreementQuoteSignatures' => $this->agreementQuoteSignatures($page['slug'] ?? null), 'agreementSubjects' => $this->agreementSubjects($page['slug'] ?? null), 'agreementCourierNames' => $agreementCourierNames, 'agreementCourierAccounts' => $agreementCourierAccounts, 'agreementQuoteVersions' => $this->agreementQuoteVersions($page['slug'] ?? null), ]; } /** @return array{0:LengthAwarePaginator,1:?string} */ private function inventoryReportRows(): array { $page = max(1, request()->integer('page', 1)); $perPage = min(max((int) setting('default.list_limit', 25), 1), 100); $filters = $this->inventoryReportFilters(request()); $error = null; try { if (! $this->thirdPartyWarehouseStorage->available()) { $error = '进销存共享镜像尚未建立,暂时无法读取。'; } elseif (! $this->thirdPartyWarehouseStorage->allWarehousesReady()) { $error = '进销存全仓库共享镜像正在执行首次同步,完成前不会展示不完整的单仓数据。'; } else { $result = $this->thirdPartyWarehouseStorage->paginateMonthlyInventoryReport( $filters, $page, $perPage, ); return [new LengthAwarePaginator( $result['data'], $result['total'], $perPage, $page, [ 'path' => request()->url(), 'query' => request()->except('page'), ], ), null]; } } catch (Throwable $exception) { report($exception); $error = '进销存共享镜像暂时无法读取,请稍后重试。'; } return [new LengthAwarePaginator([], 0, $perPage, $page, [ 'path' => request()->url(), 'query' => request()->except('page'), ]), $error]; } /** @return array */ private function inventoryReportFilters(Request $request): array { $month = trim((string) $request->query('month', now()->startOfMonth()->subMonth()->format('Y-m'))); if (preg_match('/^(\d{4})-(\d{2})$/', $month, $matches) !== 1 || ! checkdate((int) $matches[2], 1, (int) $matches[1])) { $month = now()->startOfMonth()->subMonth()->format('Y-m'); } $minimumMonth = substr(ThirdPartyWarehouseStorageMirrorRepository::INVENTORY_REPORT_START_DATE, 0, 7); if ($month < $minimumMonth) { $month = $minimumMonth; } return [ 'month' => $month, 'product_code' => mb_substr(trim((string) $request->query('product_code', '')), 0, 100), 'warehouse_provider' => mb_substr(trim((string) $request->query('warehouse_provider', '')), 0, 191), 'category' => mb_substr(trim((string) $request->query('category', '')), 0, 191), ]; } private function isStoreReceivingAccountPage(string $section, string $page): bool { return $section === 'fund-management' && $page === 'store-receiving-accounts'; } private function storeReceivingAccountRows(?string $tab): LengthAwarePaginator { $query = \App\Models\Business\StoreReceivingAccount::query(); $platforms = $tab === null ? ['淘宝', '阿里巴巴', '天猫', '淘宝分销', '唯品会', '京东', '京喜', '抖音', '拼多多'] : TableRegistry::tabValues('fund-management', 'store-receiving-accounts', $tab); if ($platforms !== []) { $query->whereIn('store_platform', $platforms); } $erpName = mb_substr(trim((string) request('erp_store_name', '')), 0, 191); if ($erpName !== '') { $query->where('store_name', 'like', '%' . addcslashes($erpName, '%_\\') . '%'); } $rows = $query->orderByDesc('id')->get(); $shopName = mb_substr(trim((string) request('shop_name', '')), 0, 191); $status = trim((string) request('store_status', '')); if ($shopName !== '' || $status !== '') { $rows = $rows->filter(function ($row) use ($shopName, $status): bool { return ($shopName === '' || str_contains((string) $row->shop_name, $shopName)) && ($status === '' || (string) $row->store_status === $status); })->values(); } $page = max((int) request('page', 1), 1); $perPage = 50; $total = $rows->count(); return new LengthAwarePaginator( $rows->forPage($page, $perPage)->values(), $total, $perPage, $page, ['path' => request()->url(), 'query' => request()->query()], ); } protected function agreementCourierNames(?string $page): Collection { if ($page !== 'agreement-signing') { return collect(); } try { return collect($this->courierInformation->names()); } catch (Throwable $exception) { // 快递镜像不可用时仍允许打开签署页,并明确展示暂无可选资料。 report($exception); return collect(); } } /** @return array */ protected function agreementCourierAccounts(?string $page, ?Collection $courierNames = null): array { if ($page !== 'agreement-signing') { return []; } try { return $this->courierInformation->signingAccountsByName( ($courierNames ?? $this->agreementCourierNames($page))->values()->all(), ); } catch (Throwable $exception) { // 快递镜像不可用时仍可手工填写 E 签宝账号,不能阻断签署页。 report($exception); return []; } } protected function agreementTasks(?string $page): Collection|\Illuminate\Contracts\Pagination\LengthAwarePaginator { if ($page !== 'agreement-signing') { return collect(); } try { $type = $this->agreementFilterType(); if ($type === 'quote-confirmation') { return collect(); } $query = CourierAgreementSignature::query(); $search = $this->agreementFilterSearch(); $status = $this->agreementFilterStatus(); if ($search !== '') { $like = '%' . addcslashes($search, '%_\\') . '%'; $query->where(function ($builder) use ($like): void { $builder ->where('business_no', 'like', $like) ->orWhere('courier_name', 'like', $like) ->orWhere('organization_name', 'like', $like) ->orWhere('operator_name', 'like', $like) ->orWhere('company_full_name', 'like', $like); }); } if ($status !== null) { $query->where('status', $status); } if ($type !== null) { $query->where('document_type', $type); } return $query ->latest('id') ->paginate(50, ['*'], 'agreement_task_page')->withQueryString(); } catch (QueryException $exception) { // 迁移尚未执行时仍允许打开页面;发送接口会明确提示数据库未就绪。 report($exception); return collect(); } } protected function agreementFilterSearch(): string { return mb_substr(trim((string) request('agreement_search', '')), 0, 191); } protected function agreementFilterStatus(): ?string { $status = trim((string) request('agreement_status', '')); $allowed = [ CourierAgreementSignature::STATUS_PREPARED, CourierAgreementSignature::STATUS_SENDING, CourierAgreementSignature::STATUS_SIGNING, CourierAgreementSignature::STATUS_COMPLETED, CourierAgreementSignature::STATUS_ARCHIVED, CourierAgreementSignature::STATUS_REVOKED, CourierAgreementSignature::STATUS_EXPIRED, CourierAgreementSignature::STATUS_REJECTED, CourierAgreementSignature::STATUS_FAILED, CourierAgreementSignature::STATUS_UNKNOWN, ]; return in_array($status, $allowed, true) ? $status : null; } protected function agreementFilterType(): ?string { $type = trim((string) request('agreement_type', '')); $allowed = array_keys(CourierAgreementSignature::DOCUMENTS); return in_array($type, $allowed, true) ? $type : null; } protected function agreementSubjects(?string $page): Collection { if ($page !== 'agreement-signing') { return collect(); } try { return CourierSigningSubject::query() ->orderByDesc('is_default') ->orderBy('name') ->get(); } catch (QueryException $exception) { // 主体管理迁移尚未执行时仍允许打开签署页面,默认使用当前公司名称。 report($exception); return collect(); } } protected function agreementQuoteSignatures(?string $page): Collection|\Illuminate\Contracts\Pagination\LengthAwarePaginator { if ($page !== 'agreement-signing') { return collect(); } try { $type = $this->agreementFilterType(); if ($type !== null && $type !== 'quote-confirmation') { return collect(); } $query = CourierQuoteSignature::query()->with('version'); $search = $this->agreementFilterSearch(); $status = $this->agreementFilterStatus(); if ($search !== '') { $like = '%' . addcslashes($search, '%_\\') . '%'; $query->where(function ($builder) use ($like): void { $builder ->where('organization_name', 'like', $like) ->orWhere('operator_name', 'like', $like) ->orWhereHas('version', function ($versionQuery) use ($like): void { $versionQuery ->where('version_code', 'like', $like) ->orWhere('carrier_name', 'like', $like); }); }); } if ($status !== null) { $query->where('status', $status); } return $query ->latest('id') ->paginate(50, ['*'], 'agreement_quote_page')->withQueryString(); } catch (QueryException $exception) { // 报价签署迁移尚未执行时仍允许打开协议签署页。 report($exception); return collect(); } } protected function agreementQuoteVersions(?string $page): Collection { if ($page !== 'agreement-signing') { return collect(); } try { return CourierQuoteVersion::query() ->where('status', 'approved') ->orderByDesc('effective_date') ->orderByDesc('id') ->limit(100) ->get(); } catch (QueryException $exception) { // 报价版本迁移尚未执行时仍允许打开协议签署页,报价确认单会提示暂无可选版本。 report($exception); return collect(); } } protected function resolveContent(string $page, ?string $tab): ?array { return match ($page) { 'marketing-account-bills' => $this->contentForMarketingAccountBills($tab), 'marketing-account-balance' => $this->contentForMarketingAccountBalance($tab), 'jingxi-refund-data' => $this->contentForJingxiRefundData(), 'non-jingxi-refund-data' => $this->contentForNonJingxiRefundData(), 'small-payment-work-orders' => $this->contentForSmallPaymentWorkOrders(), 'zhongcai-reconciliation' => $this->contentForZhongcaiReconciliation(), 'normal-products' => $this->contentForNormalProducts(), 'product-prices' => $this->contentForProductPrices(), 'courier-information' => $this->contentForCourierInformation(), 'freight-reconciliation' => $this->contentForFreightReconciliation(), 'third-party-warehouse-storage' => $this->contentForThirdPartyWarehouseStorage(), 'board-deductions' => $this->contentForBoardDeductions(), 'supplier-payables-report' => $this->contentForSupplierPayablesReport(), 'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(), 'online-daifa-price' => $this->contentForOnlineDaifaPrice(), 'courier-surcharge' => $this->contentForCourierSurcharge(), 'freight-difference', 'other-surcharge-summary', 'return-fee' => $this->contentForCourierPayableSummary($page), 'shipping-data' => $this->contentForShippingData(), 'withdrawal-management' => $this->contentForWithdrawalManagement(), 'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(), default => null, }; } protected function contentForCourierPayableSummary(string $page): array { $store = app(CourierPayableSummaryStore::class); $snapshot = null; $error = null; try { $snapshot = $store->snapshot((int) (company_id() ?: 1), $page); } catch (RuntimeException) { $error = '汇总数据暂时无法读取,请重新导入后再试。'; } $headers = $snapshot['headers'] ?? []; $allRows = collect($snapshot['rows'] ?? []); $filter = app(CourierPayableSummaryFilter::class); $filters = $filter->fromRequest(request()); $filteredRows = $filter->apply($allRows, $headers, $filters); $perPage = 50; $lastPage = max((int) ceil($filteredRows->count() / $perPage), 1); $currentPage = min(max((int) request()->query('page', 1), 1), $lastPage); return [ 'view' => 'business.pages.courier-payables.summary-table', 'courierSummaryPage' => $page, 'courierSummaryTitle' => $store->title($page), 'courierSummaryHeaders' => $headers, 'courierSummaryRows' => new LengthAwarePaginator( $filteredRows->forPage($currentPage, $perPage)->values(), $filteredRows->count(), $perPage, $currentPage, ['path' => request()->url(), 'query' => array_filter( $filters, static fn (string $value): bool => $value !== '', )], ), 'courierSummaryFilters' => $filters, 'courierSummaryHasFilters' => $filter->hasFilters($filters), 'courierSummarySourceTotal' => $allRows->count(), 'courierSummarySourceName' => $snapshot['source_name'] ?? null, 'courierSummaryImportedAt' => $snapshot['imported_at'] ?? null, 'courierSummaryError' => $error, ]; } /** * Consolidated, non-destructive view of the withdrawal configuration kept * with each store receiving account. Updates remain in the source page so * a withdrawal action cannot delete an entire store-account record. */ protected function contentForWithdrawalManagement(): array { $report = $this->withdrawalManagementReport->results(request()); $withdrawalRows = $report['rows']; $page = max((int) request()->query('page', 1), 1); $perPage = 50; $withdrawalRows = new LengthAwarePaginator( $withdrawalRows->forPage($page, $perPage)->values(), $withdrawalRows->count(), $perPage, $page, ['path' => request()->url(), 'query' => request()->except('page')], ); return [ 'view' => 'business.pages.fund-management.withdrawal-management', 'withdrawalRows' => $withdrawalRows, 'withdrawalFilters' => $report['filters'], 'withdrawalPlatforms' => $report['platforms'], 'withdrawalPaths' => $report['paths'], 'withdrawalStoreEntities' => $report['storeEntities'], 'withdrawalBalances' => $report['balances'], 'withdrawalReserveStandards' => $report['reserveStandards'], 'withdrawalAmounts' => $report['withdrawalAmounts'], 'withdrawalCompliance' => $report['compliance'], 'withdrawalBalanceSummary' => $report['summary'], 'withdrawalBalanceColumnLabel' => $report['balanceColumnLabel'], 'withdrawalBalanceDate' => $report['balanceDate'], 'withdrawalSort' => $report['sort'], ]; } /** * 快递附加费报价维护。 * * 报价维护与后续附加费结算分开:本页只负责渠道、类型、结算方式、 * 计价标准和生效时间的维护展示,结算页将来根据这些规则读取业务镜像。 */ protected function contentForShippingData(): array { $request = request(); $filters = $this->shippingDataFilters($request); $mode = $request->query('mode') === 'detail' ? 'detail' : 'merged'; $page = max((int) $request->query('page', 1), 1); $limit = min(max((int) setting('default.list_limit', 25), 1), 100); $rows = new LengthAwarePaginator([], 0, $limit, $page, [ 'path' => $request->url(), 'query' => $request->except('page'), ]); $sync = ['code' => 'unavailable', 'last_succeeded_at' => null]; $error = null; try { $result = $this->shippingOrders->paginate($filters, $page, $limit, $mode); $rows = new LengthAwarePaginator( Collection::make($result['data'] ?? []), (int) ($result['total'] ?? 0), $limit, $page, ['path' => $request->url(), 'query' => $request->except('page')], ); $sync = $this->shippingOrders->syncStatus(); } catch (Throwable $exception) { report($exception); $error = '发货数据暂时无法读取(可能尚未运行迁移或同步)。'; } return [ 'view' => 'business.pages.courier-payables.shipping-data', 'shippingRows' => $rows, 'shippingFilters' => $filters, 'shippingSync' => $sync, 'shippingError' => $error, 'shippingMode' => $mode, 'shippingExportUrl' => route('business.courier-payables.shipping-data.export'), ]; } public function exportShippingData(Request $request): StreamedResponse { $filters = $this->shippingDataFilters($request); $mode = $request->query('mode') === 'detail' ? 'detail' : 'merged'; $columns = $this->shippingDataColumns(); return response()->streamDownload(function () use ($filters, $columns, $mode): void { $csv = fopen('php://output', 'w'); if ($csv === false) { throw new \RuntimeException('无法创建发货数据导出流。'); } fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); fputcsv($csv, array_column($columns, 'label')); foreach ($this->shippingOrders->exportRows($filters, 1000, $mode) as $row) { fputcsv($csv, array_map(function (array $column) use ($row): mixed { $value = data_get($row, $column['field']); // 发货数据导出:标识列(快递单号/交易单号/商品ID/条形码)按原样文本导出,不加 ' 前缀 return match ($column['type']) { 'number' => $value === null || $value === '' ? '' : $value, default => $this->csvText($value), }; }, $columns)); } fclose($csv); }, '发货数据' . ($mode === 'detail' ? '明细' : '合并') . '_' . now()->format('Ymd_His') . '.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', ]); } public function suggestShippingData(Request $request): \Illuminate\Http\JsonResponse { $allowed = ['supplier_name', 'shop_name', 'warehouse_name', 'express_company']; $field = (string) $request->query('field', ''); if (! in_array($field, $allowed, true)) { return response()->json(['items' => []]); } $keyword = mb_substr(trim((string) $request->query('q', '')), 0, 64); $limit = min(max((int) $request->query('limit', 20), 1), 500); try { $items = $this->shippingOrders->distinctValues($field, $keyword, $limit); } catch (Throwable $exception) { report($exception); $items = []; } return response()->json(['items' => array_values($items)]); } /** @return array */ private function shippingDataFilters(Request $request): array { $text = static fn (string $key, int $length = 128): string => mb_substr( trim((string) $request->query($key, '')), 0, $length, ); // 多选字段:接收 name[] 数组(也兼容单个字符串),去重、限长、限个数。 $multi = static function (string $key, int $length = 191, int $max = 50) use ($request): array { $raw = $request->query($key, []); if (is_string($raw)) { $raw = $raw === '' ? [] : [$raw]; } if (! is_array($raw)) { $raw = []; } $out = []; foreach ($raw as $value) { $value = mb_substr(trim((string) $value), 0, $length); if ($value !== '' && ! in_array($value, $out, true)) { $out[] = $value; } if (count($out) >= $max) { break; } } return $out; }; $date = static function (mixed $value): string { $value = trim((string) $value); return preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $value) === 1 ? $value : ''; }; return [ 'express_no' => $text('express_no', 80), 'sourceno' => $text('sourceno', 80), 'barcode' => $text('barcode'), 'supplier_name' => $multi('supplier_name'), 'shop_name' => $multi('shop_name', 255), 'warehouse_name' => $multi('warehouse_name'), 'express_company' => $multi('express_company'), 'ship_from' => $date($request->query('ship_from', '')), 'ship_to' => $date($request->query('ship_to', '')), ]; } /** @return list */ private function shippingDataColumns(): array { return [ ['field' => 'express_no', 'label' => '快递单号', 'type' => 'identifier'], ['field' => 'trade_no', 'label' => '交易单号', 'type' => 'identifier'], ['field' => 'item_id', 'label' => '商品ID', 'type' => 'identifier'], ['field' => 'barcode', 'label' => '条形码', 'type' => 'identifier'], ['field' => 'product_name', 'label' => '商品名称', 'type' => 'text'], ['field' => 'qty', 'label' => '数量', 'type' => 'number'], ['field' => 'pkg_count', 'label' => '件数', 'type' => 'text'], ['field' => 'receiver_state', 'label' => '省', 'type' => 'text'], ['field' => 'buyer_nick', 'label' => '买家昵称', 'type' => 'text'], ['field' => 'receiver_name', 'label' => '收货姓名', 'type' => 'text'], ['field' => 'shop_name', 'label' => '店铺名称', 'type' => 'text'], ['field' => 'express_name1', 'label' => '快递名称1', 'type' => 'text'], ['field' => 'warehouse_name', 'label' => '仓库', 'type' => 'text'], ['field' => 'ship_time', 'label' => '发货时间', 'type' => 'text'], ['field' => 'freight', 'label' => '运费', 'type' => 'number'], ['field' => 'online_weight', 'label' => '线上重量', 'type' => 'number'], ['field' => 'actual_weight', 'label' => '实称重量', 'type' => 'number'], ['field' => 'length_cm', 'label' => '长(cm)', 'type' => 'number'], ['field' => 'width_cm', 'label' => '宽(cm)', 'type' => 'number'], ['field' => 'height_cm', 'label' => '高(cm)', 'type' => 'number'], ['field' => 'volume_cm3', 'label' => '体积(cm³)', 'type' => 'number'], ['field' => 'receiver_city', 'label' => '市', 'type' => 'text'], ['field' => 'receiver_district', 'label' => '区', 'type' => 'text'], ['field' => 'supplier_name', 'label' => '供应商', 'type' => 'text'], ['field' => 'receiver_address', 'label' => '收货详细地址', 'type' => 'text'], ['field' => 'receiver_mobile', 'label' => '手机号码', 'type' => 'identifier'], ['field' => 'receiver_phone', 'label' => '座机号码', 'type' => 'identifier'], ['field' => 'express_name2', 'label' => '快递名称2', 'type' => 'text'], ['field' => 'platform', 'label' => '平台', 'type' => 'text'], ['field' => 'buyer_memo', 'label' => '买家留言', 'type' => 'text'], ['field' => 'seller_memo', 'label' => '卖家备注', 'type' => 'text'], ['field' => 'package_no', 'label' => '配货单号', 'type' => 'identifier'], ['field' => 'amount', 'label' => '金额', 'type' => 'number'], ['field' => 'product_code', 'label' => '货号', 'type' => 'identifier'], ['field' => 'download_time', 'label' => '下载时间', 'type' => 'text'], ['field' => 'pay_time', 'label' => '付款时间', 'type' => 'text'], ['field' => 'freight_rate', 'label' => '费率', 'type' => 'number'], ['field' => 'freight_formula', 'label' => '公式', 'type' => 'text'], ['field' => 'weight_kg', 'label' => '重量(kg)', 'type' => 'number'], ['field' => 'settle_weight_kg', 'label' => '结算重量(kg)', 'type' => 'number'], ['field' => 'settle_volume_cm3', 'label' => '结算体积(cm³)', 'type' => 'number'], ['field' => 'recon_weight_kg', 'label' => '对账重量(kg)', 'type' => 'number'], ['field' => 'recon_volume_cm3', 'label' => '对账体积(cm³)', 'type' => 'number'], ['field' => 'finished_barcode', 'label' => '成品条码', 'type' => 'identifier'], ['field' => 'finished_name', 'label' => '成品名称', 'type' => 'text'], ['field' => 'finished_supplier', 'label' => '成品供应商', 'type' => 'text'], ['field' => 'settle_freight', 'label' => '结算运费', 'type' => 'number'], ['field' => 'front_no', 'label' => '换单前单号', 'type' => 'identifier'], ]; } protected function contentForCourierSurcharge(): array { $clean = static fn (mixed $value, int $length = 60): string => mb_substr(trim((string) $value), 0, $length); $filters = [ 'keyword' => $clean(request('keyword', ''), 40), 'courier' => $clean(request('courier', ''), 40), 'type' => $clean(request('type', ''), 40), 'mode' => $clean(request('mode', ''), 40), 'scope' => $clean(request('scope', ''), 20), 'reconciler' => $clean(request('reconciler', ''), 40), 'supplier' => $clean(request('supplier', ''), 191), 'status' => $clean(request('status', ''), 20), ]; $data = $this->courierSurchargeQuotes->pageData($filters); $auditReady = $this->courierSurchargeAudits->ready(); return [ 'view' => 'business.pages.courier-payables.courier-surcharge', 'courierSurchargeRows' => $data['rows'], 'courierSurchargeFilters' => $filters, 'courierSurchargeSummary' => $data['summary'], 'courierSurchargeOptions' => $data['options'], 'courierSurchargeReady' => $data['ready'] && $auditReady, 'courierSurchargeDataReady' => $data['ready'], 'courierSurchargeAuditReady' => $auditReady, 'courierSurchargeTypeUsage' => $this->courierSurchargeTypes->usageCounts($data['options']['type_records'] ?? collect()), 'courierSurchargeAuditLogs' => $this->courierSurchargeAudits->recent(), ]; } private function freightReconciliationFilters(): array { $request = request(); $date = static function (mixed $value): string { $value = trim((string) $value); return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : ''; }; return [ 'express_no' => mb_substr(trim((string) $request->query('express_no', '')), 0, 80), 'express_name' => mb_substr(trim((string) $request->query('express_name', '')), 0, 80), 'month' => mb_substr(trim((string) $request->query('month', '')), 0, 20), 'status' => mb_substr(trim((string) $request->query('status', '')), 0, 40), 'modified_from' => $date($request->query('modified_from', '')), 'modified_to' => $date($request->query('modified_to', '')), ]; } protected function contentForFreightReconciliation(): array { $request = request(); $filters = $this->freightReconciliationFilters(); $page = max((int) $request->query('page', 1), 1); $limit = min(max((int) setting('default.list_limit', 25), 1), 100); $rows = new Paginator([], $limit, $page, [ 'path' => $request->url(), 'query' => $request->except('page'), ]); $state = null; $sync = ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null]; $summary = ['count' => null, 'freight' => null]; $error = null; $pending = false; try { if (! $this->courierFreightReconciliations->available()) { $error = '共享业务镜像尚未建立,运费对账暂时无法读取。'; } elseif (! $this->courierFreightReconciliations->ready()) { $state = $this->courierFreightReconciliations->state(); $sync = $this->courierFreightReconciliations->syncStatus($state); $pending = true; $error = '运费对账镜像正在完成首次同步,完成前不会把未同步数据误判为无数据。'; } else { $result = $this->courierFreightReconciliations->paginate($filters, $page, $limit); $rows = new Paginator( Collection::make($result['data'] ?? []), $limit, $page, ['path' => $request->url(), 'query' => $request->except('page')], ); $rows->hasMorePagesWhen((bool) ($result['has_more'] ?? false)); $state = $result['state'] ?? null; $sync = $result['sync'] ?? $sync; $summary = $result['summary'] ?? $summary; } } catch (Throwable $exception) { report($exception); $error = '运费对账数据暂时无法读取,请稍后重试。'; } return [ 'view' => 'business.pages.courier-payables.freight-reconciliation', 'freightReconciliations' => $rows, 'freightReconciliationFilters' => $filters, 'freightReconciliationSummary' => $summary, 'freightReconciliationState' => $state, 'freightReconciliationSync' => $sync, 'freightReconciliationError' => $error, 'freightReconciliationPending' => $pending, 'freightReconciliationSummaryUrl' => $error === null ? $request->fullUrlWithQuery(['courier_summary' => 1, 'page' => null]) : null, ]; } protected function contentForThirdPartyWarehouseStorage(): array { $request = request(); $filters = $this->thirdPartyWarehouseStorageFilters($request); $page = max((int) $request->query('page', 1), 1); $limit = min(max((int) setting('default.list_limit', 25), 1), 100); $rows = new LengthAwarePaginator([], 0, $limit, $page, [ 'path' => $request->url(), 'query' => $request->except('page'), ]); $state = null; $sync = ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null]; $summary = []; $error = null; $pending = false; try { if (! $this->thirdPartyWarehouseStorage->available()) { $error = '共享业务镜像尚未建立,三方仓进销存暂时无法读取。'; } elseif (! $this->thirdPartyWarehouseStorage->ready()) { $state = $this->thirdPartyWarehouseStorage->state(); $sync = $this->thirdPartyWarehouseStorage->syncStatus($state); $pending = true; $error = ($sync['code'] ?? null) === 'dependency_pending' ? '普通商品资料镜像正在执行首次全量同步,完成前不会展示口径不完整的商品属性。' : '三方仓进销存正在执行首次全量同步,完成前不会把未同步数据误判为无数据。'; } else { $result = $this->thirdPartyWarehouseStorage->paginate($filters, $page, $limit); $rows = new LengthAwarePaginator( Collection::make($result['data'] ?? []), (int) ($result['total'] ?? 0), $limit, $page, [ 'path' => $request->url(), 'query' => $request->except('page'), ], ); $state = $result['state'] ?? null; $sync = $result['sync'] ?? $sync; $summary = $result['summary'] ?? []; } } catch (Throwable $exception) { report($exception); $error = '三方仓进销存暂时无法读取,请稍后重试。'; } return [ 'view' => 'business.pages.courier-payables.third-party-warehouse-storage', 'thirdPartyWarehouseRows' => $rows, 'thirdPartyWarehouseFilters' => $filters, 'thirdPartyWarehouseSummary' => $summary, 'thirdPartyWarehouseState' => $state, 'thirdPartyWarehouseSync' => $sync, 'thirdPartyWarehouseError' => $error, 'thirdPartyWarehousePending' => $pending, 'thirdPartyWarehouseExportUrl' => route('business.courier-payables.third-party-warehouse-storage.export'), ]; } public function exportThirdPartyWarehouseStorage(Request $request): StreamedResponse { abort_unless( $this->thirdPartyWarehouseStorage->available() && $this->thirdPartyWarehouseStorage->ready(), 503, ); $filters = $this->thirdPartyWarehouseStorageFilters($request); $columns = $this->filterExportColumns( $this->thirdPartyWarehouseStorageExportColumns(), $request->query('fields'), ); $period = str_replace('-', '', $filters['period']); return response()->streamDownload(function () use ($filters, $columns): void { $csv = fopen('php://output', 'w'); if ($csv === false) { throw new \RuntimeException('无法创建三方仓进销存导出流。'); } fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); fputcsv($csv, array_column($columns, 'label')); foreach ($this->thirdPartyWarehouseStorage->exportRows($filters) as $row) { fputcsv($csv, array_map(function (array $column) use ($row): mixed { $value = data_get($row, $column['field']); return match ($column['type'] ?? 'text') { 'number', 'money' => $value === null || $value === '' ? '' : $value, 'code' => $this->csvIdentifier($value), default => $this->csvText($value), }; }, $columns)); } fclose($csv); }, '三方仓进销存_' . $period . '_' . now()->format('Ymd_His') . '.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', ]); } public function exportInventoryReport(Request $request): StreamedResponse { abort_unless( $this->thirdPartyWarehouseStorage->available() && $this->thirdPartyWarehouseStorage->allWarehousesReady(), 503, ); $filters = $this->inventoryReportFilters($request); $productInventory = collect(config('business_navigation'))->firstWhere('slug', 'product-inventory'); $inventoryPage = collect($productInventory['pages'] ?? [])->firstWhere('slug', 'inventory-report'); $columns = $inventoryPage['directory']['fields'] ?? []; return response()->streamDownload(function () use ($filters, $columns): void { set_time_limit(0); $csv = fopen('php://output', 'w'); if ($csv === false) { throw new RuntimeException('无法创建进销存报表导出流。'); } fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); fputcsv($csv, array_column($columns, 'label')); $written = 0; foreach ($this->thirdPartyWarehouseStorage->exportMonthlyInventoryReportRows($filters) as $row) { fputcsv($csv, array_map(function (array $column) use ($row): mixed { $value = data_get($row, $column['name']); return $column['name'] === 'product_code' ? $this->csvIdentifier($value) : (($column['kind'] ?? null) === 'integer' ? ($value === null || $value === '' ? '' : $value) : $this->csvText($value)); }, $columns)); $written++; if ($written % 500 === 0) { fflush($csv); } } fclose($csv); }, '进销存报表_' . str_replace('-', '', $filters['month']) . '_' . now()->format('Ymd_His') . '.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', ]); } /** @return array */ private function thirdPartyWarehouseStorageFilters(Request $request): array { $dimension = in_array((string) $request->query('dimension'), ['month', 'day'], true) ? (string) $request->query('dimension') : 'month'; $period = trim((string) $request->query( 'period', $dimension === 'day' ? $request->query('date', '') : $request->query('month', ''), )); if ($dimension === 'day') { if ($this->dateFilter($period) === '') { $period = now()->subDay()->toDateString(); } if ($period < ThirdPartyWarehouseStorageMirrorRepository::START_DATE) { $period = ThirdPartyWarehouseStorageMirrorRepository::START_DATE; } } elseif (preg_match('/^(\d{4})-(\d{2})$/', $period, $matches) !== 1 || ! checkdate((int) $matches[2], 1, (int) $matches[1])) { $period = now()->startOfMonth()->subMonth()->format('Y-m'); } if ($dimension === 'month' && $period < substr(ThirdPartyWarehouseStorageMirrorRepository::START_DATE, 0, 7)) { $period = substr(ThirdPartyWarehouseStorageMirrorRepository::START_DATE, 0, 7); } $clean = static fn (mixed $value, int $length): string => mb_substr(trim((string) $value), 0, $length); return [ 'dimension' => $dimension, 'period' => $period, 'sku_code' => $clean($request->query('sku_code', ''), 80), 'product_name' => $clean($request->query('product_name', ''), 120), 'supplier' => $clean($request->query('supplier', ''), 120), 'category' => $clean($request->query('category', ''), 120), ]; } /** @return array */ private function thirdPartyWarehouseStorageExportColumns(): array { return [ ['field' => 'owner', 'label' => '货主'], ['field' => 'date', 'label' => '日期'], ['field' => 'sku_code', 'label' => '商品编码', 'type' => 'code'], ['field' => 'image_url', 'label' => '图片'], ['field' => 'product_name', 'label' => '商品名称'], ['field' => 'short_name', 'label' => '商品简称'], ['field' => 'supplier', 'label' => '供应商'], ['field' => 'category', 'label' => '分类'], ['field' => 'virtual_category', 'label' => '虚拟分类'], ['field' => 'supplier_style_no', 'label' => '供应商款号', 'type' => 'code'], ['field' => 'color_spec', 'label' => '颜色规格'], ['field' => 'brand', 'label' => '品牌'], ['field' => 'unit', 'label' => '单位'], ['field' => 'opening_qty', 'label' => '期初数量', 'type' => 'number'], ['field' => 'closing_qty', 'label' => '期末数量', 'type' => 'number'], ['field' => 'processing_in', 'label' => '加工进仓', 'type' => 'number'], ['field' => 'processing_out', 'label' => '加工出仓', 'type' => 'number'], ['field' => 'purchase_in', 'label' => '采购进仓', 'type' => 'number'], ['field' => 'purchase_return', 'label' => '采购退货', 'type' => 'number'], ['field' => 'other_in', 'label' => '其它进仓', 'type' => 'number'], ['field' => 'other_return', 'label' => '其它退货', 'type' => 'number'], ['field' => 'sales_out', 'label' => '销售出仓', 'type' => 'number'], ['field' => 'sales_return', 'label' => '销售退货', 'type' => 'number'], ['field' => 'stocktake', 'label' => '盘点', 'type' => 'number'], ['field' => 'transfer_out', 'label' => '调拨出', 'type' => 'number'], ['field' => 'transfer_in', 'label' => '调拨入', 'type' => 'number'], ['field' => 'opening_aux', 'label' => '期初', 'type' => 'number'], ['field' => 'other_out', 'label' => '其它出库', 'type' => 'number'], ]; } protected function contentForDingPayableWorkflows(): array { $normalizeDate = static function (mixed $value): string { $date = trim((string) $value); if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $matches) !== 1 || ! checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1])) { return ''; } return $date; }; $status = strtoupper(mb_substr(trim((string) request('status', '')), 0, 30)); $filters = [ 'process_instance_id' => mb_substr(trim((string) request('process_instance_id', '')), 0, 100), 'created_from' => $normalizeDate(request('created_from', '')), 'finished_to' => $normalizeDate(request('finished_to', '')), 'status' => preg_match('/^[A-Z_]+$/', $status) === 1 ? $status : '', ]; $page = max((int) request('page', 1), 1); $limit = min(max((int) setting('default.list_limit', 25), 1), 100); try { $result = $this->dingPayableWorkflows->paginate($filters, $page, $limit); $rows = new LengthAwarePaginator( Collection::make($result['data'] ?? []), (int) ($result['total'] ?? 0), $limit, $page, ['path' => request()->url(), 'query' => request()->except('page')], ); return [ 'view' => 'business.pages.courier-payables.payable-cloud-warehouse-vehicle-fee', 'dingPayableWorkflowRows' => $rows, 'dingPayableWorkflowFilters' => $filters, 'dingPayableWorkflowStatus' => $result['status'] ?? 'unknown', 'dingPayableWorkflowSyncedAt' => $result['last_synced_at'] ?? null, 'dingPayableWorkflowError' => null, ]; } catch (Throwable $exception) { report($exception); return [ 'view' => 'business.pages.courier-payables.payable-cloud-warehouse-vehicle-fee', 'dingPayableWorkflowRows' => new LengthAwarePaginator([], 0, $limit, $page, [ 'path' => request()->url(), 'query' => request()->except('page'), ]), 'dingPayableWorkflowFilters' => $filters, 'dingPayableWorkflowStatus' => 'unavailable', 'dingPayableWorkflowSyncedAt' => null, 'dingPayableWorkflowError' => '钉钉应付流程镜像尚未完成同步,请稍后重试。', ]; } } protected function contentForOnlineDaifaPrice(): array { $filters = $this->onlineDaifaPriceFilters(request()); $page = max((int) request('page', 1), 1); $limit = min(max((int) setting('default.list_limit', 25), 1), 100); try { $result = $this->onlineDaifaPrices->paginate($filters, $page, $limit); $rows = new LengthAwarePaginator( Collection::make($result['data'] ?? []), (int) ($result['total'] ?? 0), $limit, $page, ['path' => request()->url(), 'query' => request()->except('page')], ); return [ 'view' => 'business.pages.product-inventory.online-daifa-price', 'onlineDaifaRows' => $rows, 'onlineDaifaFilters' => $filters, 'onlineDaifaStatus' => $result['status'] ?? 'unknown', 'onlineDaifaSyncedAt' => $result['last_synced_at'] ?? null, 'onlineDaifaSummary' => $result['summary'] ?? [], 'onlineDaifaSources' => $result['source_statuses'] ?? [], 'onlineDaifaError' => null, ]; } catch (Throwable $exception) { report($exception); return [ 'view' => 'business.pages.product-inventory.online-daifa-price', 'onlineDaifaRows' => new LengthAwarePaginator([], 0, $limit, $page, [ 'path' => request()->url(), 'query' => request()->except('page'), ]), 'onlineDaifaFilters' => $filters, 'onlineDaifaStatus' => 'unavailable', 'onlineDaifaSyncedAt' => null, 'onlineDaifaSummary' => [], 'onlineDaifaSources' => [], 'onlineDaifaError' => '线上代发价格镜像尚未完成同步,请稍后重试。', ]; } } public function exportOnlineDaifaPrice(Request $request): StreamedResponse { abort_unless($this->onlineDaifaPrices->ready(), 503); $filters = $this->onlineDaifaPriceFilters($request); return response()->streamDownload(function () use ($filters): void { $csv = fopen('php://output', 'w'); if ($csv === false) { throw new \RuntimeException('无法创建线上代发价格导出流。'); } fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); fputcsv($csv, [ '审批编号', '标题', '状态', '申请人', '收款人全称', '标签', '商品ID', '商品名称(匹配)', '店铺名称', '代发交易单号', '店铺交易单号', '发货数量', '订单实付金额', '代发订单金额', '差价', '供应商名称', '商品编码', '创建时间', ]); foreach ($this->onlineDaifaPrices->exportRows($filters) as $row) { $workflow = (array) ($row['workflow'] ?? []); $approvalNumber = trim((string) ($workflow['businessId'] ?? '')) ?: (string) ($row['process_instance_id'] ?? ''); $originator = trim((string) ($workflow['originatorUserName'] ?? '')) ?: trim((string) ($workflow['originatorUserId'] ?? '')); $matchedProduct = (array) ($row['matched_product'] ?? []); $productName = ($row['source_type'] ?? 'ding') === 'purchase' ? '' : (($matchedProduct['matched'] ?? false) ? (string) ($matchedProduct['name'] ?? '') : '(未匹配)'); fputcsv($csv, [ $this->csvIdentifier($approvalNumber), $this->csvText($workflow['title'] ?? ''), $this->csvText($row['status_label'] ?? ''), $this->csvText($originator), $this->csvText($row['payee_name'] ?? ''), $this->csvText($row['tag'] ?? ''), $this->csvIdentifier($row['product_id'] ?? ''), $this->csvText($productName), $this->csvText($row['shop_name'] ?? ''), $this->csvIdentifier($row['daifa_trade_no'] ?? ''), $this->csvIdentifier($row['shop_trade_no'] ?? ''), $row['ship_qty'] ?? null, $row['paid_amount'] ?? null, $row['daifa_amount'] ?? null, $row['price_diff'] ?? null, $this->csvText($row['supplier_name'] ?? ''), $this->csvIdentifier($row['material_no'] ?? ''), $this->csvText($row['workflow_created_at'] ?? ''), ]); } fclose($csv); }, '线上代发价格_' . now()->format('Ymd_His') . '.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', ]); } /** @return array */ private function onlineDaifaPriceFilters(Request $request): array { $clean = static fn (mixed $value, int $length = 80): string => mb_substr(trim((string) $value), 0, $length); $date = static function (mixed $value): string { $value = trim((string) $value); return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : ''; }; $status = strtoupper(mb_substr(trim((string) $request->query('status', '')), 0, 30)); return [ 'keyword' => $clean($request->query('keyword', ''), 80), 'product_id' => $clean($request->query('product_id', ''), 64), 'shop_name' => $clean($request->query('shop_name', ''), 120), 'supplier_name' => $clean($request->query('supplier_name', ''), 120), 'material_no' => $clean($request->query('material_no', ''), 120), 'status' => preg_match('/^[A-Z_]+$/', $status) === 1 ? $status : '', 'created_from' => $date($request->query('created_from', '')), 'created_to' => $date($request->query('created_to', '')), ]; } protected function contentForJingxiRefundData(): array { return [ 'view' => 'business.pages.refunds.jingxi-refund-data', ]; } protected function contentForNonJingxiRefundData(): array { $request = request(); $defaultTo = now()->toDateString(); $defaultFrom = now()->subDays(6)->toDateString(); $filters = $this->nonJingxiRefundFilters($request, $defaultFrom, $defaultTo); $page = max((int) $request->query('page', 1), 1); $limit = min(max((int) setting('default.list_limit', 25), 1), 100); $rows = new LengthAwarePaginator([], 0, $limit, 1, [ 'path' => $request->url(), 'query' => $request->query(), ]); $syncStatus = ['code' => 'unavailable', 'last_succeeded_at' => null]; $error = null; $pending = false; $indexPending = false; try { if (! $this->nonJingxiRefunds->available()) { $error = '共享业务镜像尚未建立,非京喜退款数据暂时无法读取。'; } else { $states = $this->nonJingxiRefunds->states(); $syncStatus = $this->nonJingxiRefunds->syncStatus($states); if (! $this->nonJingxiRefunds->ready()) { $pending = true; } elseif (! $this->nonJingxiRefunds->queryReady()) { $indexPending = true; } else { $result = $this->nonJingxiRefunds->paginate($filters, $page, $limit); $rows = new LengthAwarePaginator( Collection::make($result['data'] ?? []), (int) ($result['total'] ?? 0), $limit, $page, ['path' => $request->url(), 'query' => $request->query()], ); } } } catch (Throwable $exception) { report($exception); $error = '非京喜退款数据暂时无法读取,请稍后重试。'; } return [ 'view' => 'business.pages.refunds.non-jingxi-refund-data', 'filters' => $filters, 'rows' => $rows, 'refundSyncStatus' => $syncStatus, 'refundError' => $error, 'refundPending' => $pending, 'refundIndexPending' => $indexPending, ]; } public function exportNonJingxiRefunds(Request $request) { $filters = $this->nonJingxiRefundFilters($request); $redirectParameters = array_filter([ 'section' => 'refunds', 'page' => 'non-jingxi-refund-data', ...$filters, ], static fn (mixed $value): bool => $value !== '' && $value !== null); try { if (! $this->nonJingxiRefunds->available()) { return redirect()->route('business.pages.show', $redirectParameters) ->with('error', '共享业务镜像尚未建立,售后(退货退款)暂时不能导出。'); } if (! $this->nonJingxiRefunds->ready()) { return redirect()->route('business.pages.show', $redirectParameters) ->with('error', '业务镜像首次同步尚未完成,售后(退货退款)暂时不能导出。'); } if (! $this->nonJingxiRefunds->queryReady()) { return redirect()->route('business.pages.show', $redirectParameters) ->with('error', '页面查询索引尚未就绪,售后(退货退款)暂时不能导出。'); } return $this->streamNonJingxiRefundCsv( $this->nonJingxiRefunds->exportRows($filters), $filters, $redirectParameters, $request->query('columns'), ); } catch (Throwable $exception) { report($exception); return redirect()->route('business.pages.show', $redirectParameters) ->with('error', '售后(退货退款)导出暂时无法完成,请稍后重试。'); } } /** @return array{from:string,to:string,order_no:string,shop_name:string,sku_id:string} */ private function nonJingxiRefundFilters( Request $request, ?string $defaultFrom = null, ?string $defaultTo = null, ): array { $defaultTo ??= now()->toDateString(); $defaultFrom ??= now()->subDays(6)->toDateString(); $from = $this->dateFilter($request->query('from', $defaultFrom)) ?: $defaultFrom; $to = $this->dateFilter($request->query('to', $defaultTo)) ?: $defaultTo; if ($from > $to) { [$from, $to] = [$to, $from]; } return [ 'from' => $from, 'to' => $to, 'order_no' => mb_substr(trim((string) $request->query('order_no', '')), 0, 100), 'shop_name' => mb_substr(trim((string) $request->query('shop_name', '')), 0, 191), 'sku_id' => mb_substr(trim((string) $request->query('sku_id', '')), 0, 100), ]; } private function streamNonJingxiRefundCsv(iterable $rows, array $filters, array $redirectParameters, mixed $requestedFields = null) { $columns = [ ['label' => '序号/主键ID', 'field' => 'Id', 'identifier' => true], ['label' => '退款日期', 'field' => 'refundDate'], ['label' => '售后单ID', 'field' => 'as_id', 'identifier' => true], ['label' => '外部售后单号', 'field' => 'outer_as_id', 'identifier' => true], ['label' => '订单ID', 'field' => 'o_id', 'identifier' => true], ['label' => '', 'field' => ''], ['label' => '子订单ID', 'field' => 'so_id', 'identifier' => true], ['label' => '类型', 'field' => 'type'], ['label' => '节点', 'field' => 'node'], ['label' => '是否自营', 'field' => 'isZC'], ['label' => '店铺名称', 'field' => 'shop_name'], ['label' => '平台', 'field' => 'platform'], ['label' => '责任部门', 'field' => 'responsibleDepartment'], ['label' => '退款总金额', 'field' => 'totalRefundMoney', 'number' => true], ['label' => '原始邮编', 'field' => 'originPostcode'], ['label' => '店铺商品ID', 'field' => 'shop_i_id', 'identifier' => true], ['label' => '店铺SKU编码', 'field' => 'shop_sku_id', 'identifier' => true], ['label' => '商品ID', 'field' => 'i_id', 'identifier' => true], ['label' => 'SKU编码', 'field' => 'sku_id', 'identifier' => true], ['label' => '买家实付金额', 'field' => 'buyer_paid_amount', 'number' => true], ['label' => '数量', 'field' => 'qty', 'number' => true], ['label' => '商品单位成本', 'field' => 'unitCostGoods', 'number' => true], ['label' => '明细退款金额', 'field' => 'detailRefundMoney', 'number' => true], ['label' => '退款类型', 'field' => 'refundType'], ['label' => '退款数量', 'field' => 'refundQty', 'number' => true], ['label' => '退款商品成本', 'field' => 'refundCostGoods', 'number' => true], ['label' => '退款回款金额', 'field' => 'RefundMoneyIncome', 'number' => true], ['label' => '售后金额', 'field' => 'AfSalesMoney', 'number' => true], ['label' => '创建时间', 'field' => 'createTime'], ['label' => '状态', 'field' => 'status'], ['label' => '店铺状态', 'field' => 'shop_status'], ['label' => '创建记录/创建时间', 'field' => 'created'], ['label' => '确认日期', 'field' => 'confirm_date'], ]; $columns = $this->filterExportColumns($columns, $requestedFields); $csv = null; try { $csv = fopen('php://temp/maxmemory:5242880', 'w+b'); if ($csv === false) { throw new \RuntimeException('无法创建售后退款导出流。'); } fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); fputcsv($csv, array_column($columns, 'label')); foreach ($rows as $row) { fputcsv($csv, array_map( fn (array $column): mixed => $column['field'] === '' ? '' : (($column['number'] ?? false) ? data_get($row, $column['field']) : (($column['identifier'] ?? false) ? $this->csvIdentifier(data_get($row, $column['field'])) : $this->csvText(data_get($row, $column['field'])))), $columns, )); } rewind($csv); } catch (Throwable $exception) { if (is_resource($csv)) { fclose($csv); } report($exception); return redirect()->route('business.pages.show', $redirectParameters) ->with('error', '售后(退货退款)导出暂时无法完成,请稍后重试。'); } return response()->streamDownload(function () use ($csv): void { fpassthru($csv); fclose($csv); }, 'after_sales_refunds_' . $filters['from'] . '_' . $filters['to'] . '.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', ]); } protected function contentForSmallPaymentWorkOrders(): array { $request = request(); $filters = $this->smallPaymentWorkOrderFilters($request); $page = max((int) $request->query('page', 1), 1); $limit = min(max((int) setting('default.list_limit', 25), 1), 100); $rows = new LengthAwarePaginator([], 0, $limit, 1, [ 'path' => $request->url(), 'query' => $request->query(), ]); $syncStatus = ['code' => 'unavailable', 'last_succeeded_at' => null]; $pagePaymentAmount = '0.00'; $filteredPaymentAmount = '0.00'; $shopNames = []; $paymentReasons = []; $error = null; $pending = false; $indexPending = false; try { if (! $this->smallPaymentWorkOrders->available()) { $error = '共享业务镜像尚未建立,小额打款工单暂时无法读取。'; } else { $states = $this->smallPaymentWorkOrders->states(); $syncStatus = $this->smallPaymentWorkOrders->syncStatus($states); if (! $this->smallPaymentWorkOrders->ready()) { $pending = true; } elseif (! $this->smallPaymentWorkOrders->queryReady()) { $indexPending = true; } else { $result = $this->smallPaymentWorkOrders->paginate($filters, $page, $limit); $rows = new LengthAwarePaginator( Collection::make($result['data'] ?? []), (int) ($result['total'] ?? 0), $limit, $page, ['path' => $request->url(), 'query' => $request->query()], ); $pagePaymentAmount = (string) ($result['page_payment_amount'] ?? '0.00'); $filteredPaymentAmount = (string) ($result['filtered_payment_amount'] ?? '0.00'); $filterOptions = $this->smallPaymentWorkOrders->filterOptions(); $shopNames = $filterOptions['shops']; $paymentReasons = $filterOptions['payment_reasons']; } } } catch (Throwable $exception) { report($exception); $error = '小额打款工单暂时无法读取,请稍后重试。'; } return [ 'view' => 'business.pages.refunds.small-payment-work-orders', 'filters' => $filters, 'rows' => $rows, 'smallPaymentSyncStatus' => $syncStatus, 'smallPaymentPageAmount' => $pagePaymentAmount, 'smallPaymentFilteredAmount' => $filteredPaymentAmount, 'smallPaymentShops' => $shopNames, 'smallPaymentReasons' => $paymentReasons, 'smallPaymentStatuses' => [ '待审核', '审核驳回', '待确认', '确认中', '已完成', '已作废', '支付宝已打款', '银行卡已打款', '已打款', ], 'smallPaymentError' => $error, 'smallPaymentPending' => $pending, 'smallPaymentIndexPending' => $indexPending, ]; } public function exportSmallPaymentWorkOrders(Request $request) { $filters = $this->smallPaymentWorkOrderFilters($request); $redirectParameters = array_filter([ 'section' => self::SMALL_PAYMENT_SECTION, 'page' => 'small-payment-work-orders', ...$filters, ], static fn (mixed $value): bool => $value !== '' && $value !== null); try { if (! $this->smallPaymentWorkOrders->available()) { return redirect()->route('business.pages.show', $redirectParameters) ->with('error', '共享业务镜像尚未建立,小额打款工单暂时不能导出。'); } if (! $this->smallPaymentWorkOrders->ready()) { return redirect()->route('business.pages.show', $redirectParameters) ->with('error', '业务镜像首次同步尚未完成,小额打款工单暂时不能导出。'); } if (! $this->smallPaymentWorkOrders->queryReady()) { return redirect()->route('business.pages.show', $redirectParameters) ->with('error', '页面查询索引尚未就绪,小额打款工单暂时不能导出。'); } return $this->streamSmallPaymentWorkOrderCsv( $this->smallPaymentWorkOrders->exportRows($filters), $filters, $redirectParameters, $request->query('columns'), ); } catch (Throwable $exception) { report($exception); return redirect()->route('business.pages.show', $redirectParameters) ->with('error', '小额打款工单导出暂时无法完成,请稍后重试。'); } } /** * @return array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} */ private function smallPaymentWorkOrderFilters(Request $request): array { $defaultTo = now()->format('Y-m-d') . 'T23:59:59'; $defaultFrom = now()->subDays(29)->format('Y-m-d') . 'T00:00:00'; $rawFrom = $request->query('from', $defaultFrom); $rawTo = $request->query('to', $defaultTo); $from = $this->smallPaymentDateTimeFilter($rawFrom, false) ?: $defaultFrom; $to = $this->smallPaymentDateTimeFilter($rawTo, true) ?: $defaultTo; if ($from > $to) { $from = $this->smallPaymentDateTimeFilter($rawTo, false) ?: $defaultFrom; $to = $this->smallPaymentDateTimeFilter($rawFrom, true) ?: $defaultTo; } $text = static fn (mixed $value, int $length = 100): string => mb_substr(trim((string) $value), 0, $length); return [ 'from' => $from, 'to' => $to, 'work_order_no' => $text($request->query('work_order_no', '')), 'shop_name' => $text($request->query('shop_name', ''), 191), 'transaction_no' => $text($request->query('transaction_no', '')), 'online_order_no' => $text($request->query('online_order_no', '')), 'status' => $text($request->query('status', ''), 60), 'payment_reason' => $text($request->query('payment_reason', ''), 191), ]; } private function smallPaymentDateTimeFilter(mixed $value, bool $upperBound): string { $value = trim((string) $value); if (preg_match('/^(\d{4}-\d{2}-\d{2})$/', $value, $dateMatch) === 1) { if ($this->dateFilter($dateMatch[1]) === '') { return ''; } return $dateMatch[1] . ($upperBound ? 'T23:59:59' : 'T00:00:00'); } if (preg_match( '/^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/', $value, $matches, ) !== 1 || $this->dateFilter($matches[1]) === '') { return ''; } $hour = (int) $matches[2]; $minute = (int) $matches[3]; $second = isset($matches[4]) ? (int) $matches[4] : ($upperBound ? 59 : 0); if ($hour > 23 || $minute > 59 || $second > 59) { return ''; } return sprintf('%sT%02d:%02d:%02d', $matches[1], $hour, $minute, $second); } private function streamSmallPaymentWorkOrderCsv(iterable $rows, array $filters, array $redirectParameters, mixed $requestedFields = null) { $columns = [ ['label' => '工单编号', 'field' => 'work_order_no', 'identifier' => true], ['label' => '支付宝批次号', 'field' => 'alipay_batch_no', 'identifier' => true], ['label' => '工单状态', 'field' => 'work_order_status'], ['label' => '打款状态', 'field' => 'payment_status'], ['label' => '工单标题', 'field' => 'work_order_title'], ['label' => '工单内容', 'field' => 'work_order_content'], ['label' => '工单类型', 'field' => 'work_order_type'], ['label' => '优先级', 'field' => 'priority'], ['label' => '打款金额', 'field' => 'payment_amount', 'number' => true], ['label' => '催处理次数', 'field' => 'urging_count', 'number' => true], ['label' => '店铺名称', 'field' => 'shop_name'], ['label' => '线上订单号', 'field' => 'online_order_no', 'identifier' => true], ['label' => '买家账号', 'field' => 'buyer_account'], ['label' => '支付宝账号', 'field' => 'alipay_account'], ['label' => '支付宝姓名', 'field' => 'alipay_name'], ['label' => '商品编码', 'field' => 'product_code', 'identifier' => true], ['label' => 'SKU编码', 'field' => 'sku_code', 'identifier' => true], ['label' => '平台', 'field' => 'platform'], ['label' => '实际已付金额', 'field' => 'actual_paid_amount', 'number' => true], ['label' => '支付时间', 'field' => 'payment_time'], ['label' => '打款原因', 'field' => 'payment_reason'], ['label' => '创建人', 'field' => 'creator'], ['label' => '创建时间', 'field' => 'work_order_created_at'], ['label' => '完成时间', 'field' => 'completed_at'], ]; $columns = $this->filterExportColumns($columns, $requestedFields); $csv = null; try { $csv = fopen('php://temp/maxmemory:5242880', 'w+b'); if ($csv === false) { throw new \RuntimeException('无法创建小额打款工单导出流。'); } fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); fputcsv($csv, array_column($columns, 'label')); foreach ($rows as $row) { fputcsv($csv, array_map( fn (array $column): mixed => ($column['number'] ?? false) ? data_get($row, $column['field']) : (($column['identifier'] ?? false) ? $this->csvIdentifier(data_get($row, $column['field'])) : $this->csvText(data_get($row, $column['field']))), $columns, )); } rewind($csv); } catch (Throwable $exception) { if (is_resource($csv)) { fclose($csv); } report($exception); return redirect()->route('business.pages.show', $redirectParameters) ->with('error', '小额打款工单导出暂时无法完成,请稍后重试。'); } $filenameFrom = str_replace(['T', ':'], ['_', '-'], $filters['from']); $filenameTo = str_replace(['T', ':'], ['_', '-'], $filters['to']); return response()->streamDownload(function () use ($csv): void { fpassthru($csv); fclose($csv); }, 'small_payment_work_orders_' . $filenameFrom . '_' . $filenameTo . '.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', ]); } 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', 'taobao-wechat', 'pinduoduo', 'jd-wallet'], 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'; $isAlibaba = $tab === 'taobao-wechat'; $isJd = $tab === 'jd-wallet'; $repository = match ($tab) { 'taobao-alipay' => app(TmallTaobaoSpreadBillRepository::class), 'taobao-wechat' => app(AlibabaAccountBalanceRepository::class), 'jd-wallet' => app(JdPromotionAccountBalanceRepository::class), default => app(PinduoduoSpreadBillRepository::class), }; $scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list'; $cache = ($isAlibaba || $isJd) ? null : $this->upstreamCaches->forSource('jiansun'); $usingMirror = $repository->usesBusinessMirror(); try { if ($isJd && ! $usingMirror) { throw new ServiceUnavailableHttpException(null, '京东账户余额共享业务镜像尚未完成同步,请稍后重试。'); } $resolver = fn () => $repository->paginateMonthlyBalances( $filters['store_name'], $filters['month'], $page, $pageSize, ); $result = ($usingMirror || $isAlibaba || $isJd) ? $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); $platformName = $isTaobao ? '淘系' : ($isAlibaba ? '阿里巴巴' : ($isJd ? '京东' : '拼多多')); $content['error'] = $platformName . '账户余额数据暂时无法读取,请稍后重试。'; $result = ['rows' => [], 'total' => 0]; } try { $resolver = fn () => $repository->storeOptions(); $storeOptions = ($usingMirror || $isAlibaba || $isJd) ? $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(in_array($section, ['billing-transactions', self::ACCOUNT_BALANCE_SECTION], true), 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, '当前账号没有该页面权限。'); abort_unless(collect($pageConfig['tabs'] ?? [])->pluck('slug')->contains($tab), 404); if ($page === 'marketing-account-bills') { abort_unless($section === 'billing-transactions', 404); if (in_array($tab, self::PROMOTION_MARKETING_TABS, true)) { return $this->exportPlatformPromotionAccounts($request, $section, $page, $tab); } return $this->exportMarketingAccountBills($request, $section, $page, $tab); } abort_unless($section === self::ACCOUNT_BALANCE_SECTION && $page === 'marketing-account-balance', 404); abort_unless(in_array($tab, ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet'], 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'; $isAlibaba = $tab === 'taobao-wechat'; $isJd = $tab === 'jd-wallet'; $repository = match ($tab) { 'taobao-alipay' => app(TmallTaobaoSpreadBillRepository::class), 'taobao-wechat' => app(AlibabaAccountBalanceRepository::class), 'jd-wallet' => app(JdPromotionAccountBalanceRepository::class), default => 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 { $usingMirror = $repository->usesBusinessMirror(); if ($isJd && ! $usingMirror) { throw new ServiceUnavailableHttpException(null, '京东账户余额共享业务镜像尚未完成同步。'); } $resolver = fn () => $repository->paginateMonthlyBalances($filters['store_name'], $filters['month'], 1, 5000); $result = ($usingMirror || $isAlibaba || $isJd) ? $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', $isJd ? '京东账户余额共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。' : '首份共享快照正在准备,系统已登记刷新请求;请稍后刷新页面后再导出。'); } catch (Throwable $exception) { report($exception); return redirect()->route('business.pages.show', $redirectParameters) ->with('error', '余额数据导出暂时无法完成,请稍后重试。'); } $rows = $result['rows'] ?? []; $filePrefix = $isTaobao ? 'taobao' : ($isAlibaba ? 'alibaba' : ($isJd ? 'jd' : 'pinduoduo')); 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); }, $filePrefix . '_account_balances_' . ($month !== '' ? $month : 'all') . '.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', ]); } private function exportPlatformPromotionAccounts( Request $request, string $section = 'billing-transactions', string $page = 'marketing-account-bills', ?string $tab = null, ) { $platform = PlatformPromotionAccountPageData::platformForTab($tab); abort_if($platform === null, 404); $month = trim((string) $request->query('month', '')); $month = preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : null; $filters = [ 'month' => $month, 'shop_name' => mb_substr(trim((string) $request->query('shop_name', '')), 0, 100), 'account' => mb_substr(trim((string) $request->query('account', '')), 0, 100), ]; if ($platform === 'vip') { $filters['advertiser_id'] = mb_substr(trim((string) $request->query('advertiser_id', '')), 0, 100); } try { $repository = app(\Modules\EcommerceFinance\Repositories\PlatformPromotionAccountRepository::class); $filters['month'] = $month ?: ($repository->latestMonth() ?: now()->format('Y-m')); $data = $repository->dashboard($filters, 5000, $platform); } catch (Throwable $exception) { report($exception); return redirect()->route('business.pages.show', array_filter([ 'section' => $section, 'page' => $page, 'tab' => $tab, ], static fn (mixed $value): bool => $value !== null && $value !== '')) ->with('error', '推广账户数据导出暂时无法完成,请稍后重试。'); } return response()->streamDownload(function () use ($data, $platform): void { $csv = fopen('php://output', 'w'); fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); if ($platform === 'vip') { fputcsv($csv, [ '平台', '营销账户', '账户名称', '广告主 ID', '广告主名称', '报表日期', '花费(元)', '曝光量', '更新时间', ]); foreach ($data['vipMarketingDaily'] ?? [] as $row) { fputcsv($csv, [ '唯品会推广流水', $row['account'] ?? '', $row['accountName'] ?? '', $row['advertiserId'] ?? '', $row['advertiserTitle'] ?? '', $row['reportDate'] ?? '', $row['costYuan'] ?? '', $row['impressionCount'] ?? '', $row['updatedAt'] ?? '', ]); } fclose($csv); return; } if ($platform === 'jd') { fputcsv($csv, [ '平台', '店铺', '账户', '日期', '记账时间', '收支类型', '交易类型', '金额(元)', '余额(元)', '备注', ]); foreach ($data['jd'] ?? [] as $row) { fputcsv($csv, [ '京东京准通', $row['shop_name'] ?? '', $row['account'] ?? '', $row['show_date'] ?? '', $row['settle_datetime'] ?? '', $row['in_out_type_name'] ?? '', $row['trade_type_name'] ?? '', $row['amount'] ?? '', $row['balance_amount'] ?? '', $row['remark'] ?? '', ]); } fclose($csv); return; } fputcsv($csv, ['平台', '店铺/账户', '日期', '类型', '金额(元)', '余额(元)', '备注']); $emit = static function (string $platform, array $rows, array $map) use ($csv): void { foreach ($rows as $row) { fputcsv($csv, [ $platform, $map['name']($row), $map['date']($row), $map['type']($row), $map['amount']($row), $map['balance']($row), $map['remark']($row), ]); } }; if ($platform === 'douyin') { $emit('抖音千川', $data['dy'] ?? [], ['name' => fn ($row) => $row['shopName'] . ' / ' . $row['account'] . ' / ' . $row['qianchuanAavid'], 'date' => fn ($row) => $row['statDate'] ?? '', 'type' => fn () => '花费', 'amount' => fn ($row) => $row['cost'] ?? '', 'balance' => fn ($row) => $row['balance'] ?? '', 'remark' => fn ($row) => $row['remark'] ?? '']); } elseif ($platform === 'dewu') { $emit('得物推广', $data['dw'] ?? [], ['name' => fn ($row) => $row['shop_name'] . ' / ' . $row['account'], 'date' => fn ($row) => $row['trade_date'] ?? $row['date_time'], 'type' => fn ($row) => $row['flow_type'] ?? '', 'amount' => fn ($row) => $row['trade_amount_yuan'] ?? '', 'balance' => fn ($row) => $row['balance_yuan'] ?? '', 'remark' => fn ($row) => $row['remark'] ?? '']); } fclose($csv); }, '店铺营销账户账单_' . $platform . '_' . $filters['month'] . '.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', ]); } private function exportMarketingAccountBills( Request $request, string $section, string $page, string $tab, ) { abort_unless(in_array($tab, ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet'], true), 404); return match ($tab) { 'taobao-alipay' => $this->exportTaobaoAccountBills($request, $section, $page, $tab), 'taobao-wechat' => $this->exportAlibabaAccountBills($request, $section, $page, $tab), 'jd-wallet' => $this->exportJdAccountBills($request, $section, $page, $tab), default => $this->exportPinduoduoAccountBills($request, $section, $page, $tab), }; } private function exportTaobaoAccountBills(Request $request, string $section, string $page, string $tab) { $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') : ''; $redirectParameters = $this->marketingBillRedirectParameters($section, $page, $tab, [ 'store_name' => $storeName, 'from' => $fromDate, 'to' => $toDate, 'flow_type' => $flowType, ]); $repository = app(TmallTaobaoSpreadBillRepository::class); if (! $repository->usesBusinessMirror()) { return redirect()->route('business.pages.show', $redirectParameters) ->with('error', '淘系账单共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。'); } return $this->streamMarketingAccountBillCsv( $repository->exportRows( $storeName !== '' ? $storeName : null, $fromDate !== '' ? $fromDate : null, $toDate !== '' ? $toDate : null, $flowType !== '' ? $flowType : null, ), ['店铺名称', '记账日期', '交易日期', '金额(元)', '余额(元)', '备注', 'finType', '收支类型', '交易类型'], static fn (array $row): array => [ $row['store_name'] ?? '', $row['bookkeeping_time'] ?? '', $row['transaction_date'] ?? '', $row['amount'] ?? 0, $row['balance'] ?? 0, $row['comment'] ?? '', $row['fin_type'] ?? '', $row['income_expense_type'] ?? '', $row['transaction_type'] ?? '', ], 'taobao_account_bills_' . $this->marketingBillRange($fromDate, $toDate) . '.csv', $redirectParameters, '淘系账单导出暂时无法完成,请稍后重试。', ); } private function exportAlibabaAccountBills(Request $request, string $section, string $page, string $tab) { $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')); $redirectParameters = $this->marketingBillRedirectParameters($section, $page, $tab, [ 'shop_name' => $shopName, 'product_type' => $productType, 'from' => $fromDate, 'to' => $toDate, ]); $repository = app(AlibabaBillRepository::class); if (! $repository->usesBusinessMirror()) { return redirect()->route('business.pages.show', $redirectParameters) ->with('error', '阿里巴巴账单共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。'); } return $this->streamMarketingAccountBillCsv( $repository->exportRows( $shopName !== '' ? $shopName : null, $productType !== '' ? $productType : null, $fromDate !== '' ? $fromDate : null, $toDate !== '' ? $toDate : null, ), ['店铺名称', '交易时间', '产品类型', '入账金额(元)', '出帐金额(元)'], static fn (array $row): array => [ $row['shop_name'] ?? '', $row['trade_day'] ?? '', $row['product_type'] ?? '', $row['income_amount'] ?? 0, $row['expense_amount'] ?? 0, ], 'alibaba_account_bills_' . $this->marketingBillRange($fromDate, $toDate) . '.csv', $redirectParameters, '阿里巴巴账单导出暂时无法完成,请稍后重试。', ); } private function exportJdAccountBills(Request $request, string $section, string $page, string $tab) { $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')); $redirectParameters = $this->marketingBillRedirectParameters($section, $page, $tab, [ 'jd_ledger' => $ledger, 'store_name' => $storeName, 'from' => $fromDate, 'to' => $toDate, ]); $repository = app(JdWalletBillRepository::class); if (! $repository->usesBusinessMirror($ledger)) { return redirect()->route('business.pages.show', $redirectParameters) ->with('error', '京东账单共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。'); } $headers = $ledger === 'consumption' ? ['店铺名称', '金额(元)', '余额(元)', '交易类型', '投放日期', '结算日期', '流水号'] : ['店铺名称', '金额(元)', '余额(元)', '交易类型', '商户订单号', '充值时间', '到账时间', '流水号', '状态', '备注']; $rowValues = $ledger === 'consumption' ? static fn (array $row): array => [ $row['store_name'] ?? '', $row['amount'] ?? 0, $row['balance'] ?? 0, $row['transaction_type'] ?? '', $row['investment_date'] ?? '', $row['settlement_date'] ?? '', $row['serial_number'] ?? '', ] : static fn (array $row): array => [ $row['store_name'] ?? '', $row['amount'] ?? 0, $row['balance'] ?? 0, $row['transaction_type'] ?? '', $row['order_number'] ?? '', $row['recharge_time'] ?? '', $row['arrival_time'] ?? '', $row['serial_number'] ?? '', $row['status'] ?? '', $row['notes'] ?? '', ]; return $this->streamMarketingAccountBillCsv( $repository->exportRows( $ledger, $storeName !== '' ? $storeName : null, $fromDate !== '' ? $fromDate : null, $toDate !== '' ? $toDate : null, ), $headers, $rowValues, 'jd_' . $ledger . '_account_bills_' . $this->marketingBillRange($fromDate, $toDate) . '.csv', $redirectParameters, '京东账单导出暂时无法完成,请稍后重试。', ); } private function exportPinduoduoAccountBills(Request $request, string $section, string $page, string $tab) { $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') : ''; $redirectParameters = $this->marketingBillRedirectParameters($section, $page, $tab, [ 'store_name' => $storeName, 'from' => $fromDate, 'to' => $toDate, 'flow_type' => $flowType, ]); $repository = app(PinduoduoSpreadBillRepository::class); if (! $repository->usesBusinessMirror()) { return redirect()->route('business.pages.show', $redirectParameters) ->with('error', '拼多多账单共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。'); } return $this->streamMarketingAccountBillCsv( $repository->exportRows( $storeName !== '' ? $storeName : null, $fromDate !== '' ? $fromDate : null, $toDate !== '' ? $toDate : null, $flowType !== '' ? $flowType : null, ), ['店铺名称', '金额(元)', '余额(元)', '摘要', '收支类型', '资金类型', '费用时间'], static fn (array $row): array => [ $row['store_name'] ?? '', $row['amount'] ?? 0, $row['balance'] ?? 0, $row['summary'] ?? '', $row['income_expense_type'] ?? '', $row['fund_type'] ?? '', $row['cost_time'] ?? '', ], 'pinduoduo_account_bills_' . $this->marketingBillRange($fromDate, $toDate) . '.csv', $redirectParameters, '拼多多账单导出暂时无法完成,请稍后重试。', ); } private function streamMarketingAccountBillCsv( iterable $rows, array $headers, callable $rowValues, string $filename, array $redirectParameters, string $errorMessage, ) { $csv = null; try { $csv = fopen('php://temp/maxmemory:5242880', 'w+b'); if ($csv === false) { throw new \RuntimeException('无法创建账单导出临时文件。'); } fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); fputcsv($csv, $headers); foreach ($rows as $row) { fputcsv($csv, $rowValues($row)); } rewind($csv); } catch (Throwable $exception) { if (is_resource($csv)) { fclose($csv); } report($exception); return redirect()->route('business.pages.show', $redirectParameters) ->with('error', $errorMessage); } return response()->streamDownload(function () use ($csv): void { fpassthru($csv); fclose($csv); }, $filename, [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', ]); } private function marketingBillRedirectParameters(string $section, string $page, string $tab, array $filters): array { return array_filter(array_merge([ 'section' => $section, 'page' => $page, 'tab' => $tab, ], $filters), static fn (mixed $value): bool => $value !== '' && $value !== null); } private function marketingBillRange(string $fromDate, string $toDate): string { return ($fromDate ?: 'all') . '_' . ($toDate ?: 'all'); } 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), ); $rows = $this->supplierPayableOverrides->decorate( 'board-deductions', (array) ($result['rows'] ?? []), (int) (company_id() ?: 1), ); return [ 'boardDeductions' => $this->supplierPayablePaginator($rows, $limit, $page), 'boardDeductionFilters' => $filters, 'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('board-deductions'), 'boardDeductionStatus' => $cache->responseHeaders(), 'boardDeductionError' => null, ]; } catch (ServiceUnavailableHttpException $exception) { return [ 'boardDeductions' => $this->supplierPayablePaginator([], $limit, $page), 'boardDeductionFilters' => $filters, 'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('board-deductions'), 'boardDeductionStatus' => [], 'boardDeductionError' => $exception->getMessage(), ]; } catch (Throwable $exception) { report($exception); return [ 'boardDeductions' => $this->supplierPayablePaginator([], $limit, $page), 'boardDeductionFilters' => $filters, 'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('board-deductions'), '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), ); $rows = $this->supplierPayableOverrides->decorate( 'supplier-payables-report', (array) ($result['rows'] ?? []), (int) (company_id() ?: 1), ); return [ 'supplierPayables' => $this->supplierPayablePaginator($rows, $limit, $page), 'supplierPayableFilters' => $filters, 'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('supplier-payables-report'), 'supplierPayableStatus' => $cache->responseHeaders(), 'supplierPayableError' => null, ]; } catch (ServiceUnavailableHttpException $exception) { return [ 'supplierPayables' => $this->supplierPayablePaginator([], $limit, $page), 'supplierPayableFilters' => $filters, 'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('supplier-payables-report'), 'supplierPayableStatus' => [], 'supplierPayableError' => $exception->getMessage(), ]; } catch (Throwable $exception) { report($exception); return [ 'supplierPayables' => $this->supplierPayablePaginator([], $limit, $page), 'supplierPayableFilters' => $filters, 'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('supplier-payables-report'), '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 = $this->normalProductFilters(request()); $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, 'productCatalogPurchasePriceSyncStatus' => $result['purchase_price_sync_status'] ?? [ 'code' => 'unavailable', 'initialized' => false, 'last_succeeded_at' => null, ], 'productCatalogWorkPlatformPriceSyncStatus' => $result['work_platform_price_sync_status'] ?? [ 'code' => 'unavailable', 'initialized' => false, 'last_succeeded_at' => null, ], 'productCatalogPriceChangeSyncStatus' => $result['price_change_sync_status'] ?? [ 'code' => 'unavailable', 'initialized' => false, 'last_succeeded_at' => null, ], 'productCatalogJushuitanCostPriceHistorySyncStatus' => $result['jushuitan_cost_price_history_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], ); } } public function exportNormalProducts(Request $request): StreamedResponse { abort_unless($this->productCatalog->available() && $this->productCatalog->ready(), 503); $filters = $this->normalProductFilters($request); return response()->streamDownload(function () use ($filters): void { $csv = fopen('php://output', 'w'); if ($csv === false) { throw new \RuntimeException('无法创建普通商品资料导出流。'); } fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); fputcsv($csv, [ 'ID', '款式编码', '商品编码', '商品名称', '商品简称', '规格 / 颜色', '品牌', '类目', '供应商', '销售价', '成本价', '普通商品采购价', '工作平台价格', '工作平台价格生效日期', '定价状态', '变价前价格', '变价后价格', '差额', '变价时间', '数据来源', '市场价', '商品状态', '库存同步', '业务更新时间', ]); foreach ($this->productCatalog->exportRows($filters) as $row) { $specification = collect([$row->properties_value, $row->color]) ->filter(static fn (mixed $value): bool => $value !== null && $value !== '') ->unique() ->implode(' / '); $enabledLabel = match ((int) $row->enabled) { 1 => '已启用', -1 => '已删除', default => '已停用', }; fputcsv($csv, [ $row->autoid, $this->csvText($row->i_id), $this->csvText($row->sku_id), $this->csvText($row->name), $this->csvText($row->short_name), $this->csvText($specification), $this->csvText($row->brand), $this->csvText($row->category), $this->csvText($row->supplier_name), $row->sale_price, $row->cost_price, $row->purchase_price, $row->work_platform_price ?? null, $this->workPlatformPriceDate($row->work_platform_price_effect_time ?? null), $row->pricing_status ?? '未定价', $row->price_change_before_price ?? null, $row->price_change_after_price ?? null, $row->price_change_diff_amount ?? null, $this->workPlatformPriceDate($row->price_change_effect_time ?? null), implode(';', $row->data_sources), $row->market_price, $enabledLabel, (int) $row->stock_disabled === 0 ? '正常同步' : '停止同步', $row->modified, ]); } fclose($csv); }, '普通商品资料_' . now()->format('Ymd_His') . '.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', ]); } private function workPlatformPriceDate(mixed $value): string { if (! is_string($value) || trim($value) === '') { return ''; } try { return Carbon::parse($value)->format('Y-m-d'); } catch (Throwable) { return ''; } } public function exportCourierInformation(Request $request): StreamedResponse { abort_unless($this->courierInformation->available() && $this->courierInformation->ready(), 503); $filters = $this->courierInformationFilters($request); return response()->streamDownload(function () use ($filters): void { $csv = fopen('php://output', 'w'); if ($csv === false) { throw new \RuntimeException('无法创建快递资料导出流。'); } fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); fputcsv($csv, [ 'ID', '快递公司代码', '快递公司名称', '外部编码', '杉橙快递代码', '运费打款方式', '是否启用', '可达服务', '是否快运', '自动寻号', '配货单号寻号', '数量拆单', '重量0寻号', '停运时间', '合同开始时间', '合同结束时间', '联系人', '联系电话', '自定义1', '自定义2', '自定义3', '体积0寻号', '手工单号', '解密', '电子签章经办人', '最后修改', ]); $bool = static fn (mixed $value): string => in_array(strtolower((string) $value), ['1', 'true', 'yes', 'y'], true) || $value === true ? '是' : '否'; $date = static fn (mixed $value): string => $value === null || $value === '' ? '' : substr((string) $value, 0, 10); foreach ($this->courierInformation->exportRows($filters) as $row) { fputcsv($csv, [ $row->Id, $this->csvText($row->expcode), $this->csvText($row->expname), $this->csvText($row->outcode), $this->csvText($row->scexpcode), $this->csvText($row->paymethod), $bool($row->status), $bool($row->addvalidsgn), $bool($row->kuaiyunsgn), $bool($row->autonosgn), $bool($row->packnosgn), $bool($row->skucountsplitsgn), $bool($row->weight0sgn), $date($row->stopdate), $date($row->tradebtime), $date($row->tradeetime), $this->csvText($row->linker), $this->csvText($row->linkmobile), $this->csvText($row->undefined1), $this->csvText($row->undefined2), $this->csvText($row->undefined3), $bool($row->volume0sgn), $bool($row->handnosgn), $bool($row->decryptsgn), $this->csvText($row->dzqzjbr), $date($row->modified), ]); } fclose($csv); }, '快递资料_' . now()->format('Ymd_His') . '.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', ]); } /** @return array */ private function normalProductFilters(Request $request): array { $filters = [ 'i_id' => mb_substr(trim((string) $request->query('i_id', '')), 0, 50), 'sku_id' => mb_substr(trim((string) $request->query('sku_id', '')), 0, 50), 'keyword' => mb_substr(trim((string) $request->query('keyword', '')), 0, 100), 'brand' => mb_substr(trim((string) $request->query('brand', '')), 0, 100), 'category' => mb_substr(trim((string) $request->query('category', '')), 0, 100), 'supplier_name' => mb_substr(trim((string) $request->query('supplier_name', '')), 0, 191), 'enabled' => in_array((string) $request->query('enabled', ''), ['-1', '0', '1'], true) ? (string) $request->query('enabled') : '', 'stock_disabled' => in_array((string) $request->query('stock_disabled', ''), ['0', '1'], true) ? (string) $request->query('stock_disabled') : '', 'sale_price_min' => $this->decimalFilter($request->query('sale_price_min')), 'sale_price_max' => $this->decimalFilter($request->query('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'], ]; } return $filters; } private function csvText(mixed $value): string { $text = trim((string) ($value ?? '')); return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text; } /** * Keep client-selected exports within the fields declared by the page. * An omitted or invalid selection falls back to the complete safe schema. * * @param array $columns * @return array */ private function filterExportColumns(array $columns, mixed $requestedFields): array { if (! is_string($requestedFields) || trim($requestedFields) === '') { return $columns; } $available = []; foreach ($columns as $column) { if ($column['field'] !== '') { $available[$column['field']] = $column; } } $selected = []; foreach (explode(',', $requestedFields) as $field) { $field = trim($field); if ($field !== '' && isset($available[$field])) { $selected[$field] = $available[$field]; } } return $selected !== [] ? array_values($selected) : $columns; } /** * Export identifiers as text so Excel does not round or truncate long IDs. * Keep the formula-injection guard from csvText for values beginning with a * formula marker. */ private function csvIdentifier(mixed $value): string { $text = $this->csvText($value); if ($text === '' || str_starts_with($text, "'")) { return $text; } return "'" . $text; } 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, 'productCatalogPurchasePriceSyncStatus' => [ 'code' => 'unavailable', 'initialized' => false, 'last_succeeded_at' => null, ], 'productCatalogWorkPlatformPriceSyncStatus' => [ 'code' => 'unavailable', 'initialized' => false, 'last_succeeded_at' => null, ], 'productCatalogPriceChangeSyncStatus' => [ 'code' => 'unavailable', 'initialized' => false, 'last_succeeded_at' => null, ], 'productCatalogJushuitanCostPriceHistorySyncStatus' => [ 'code' => 'unavailable', 'initialized' => false, 'last_succeeded_at' => null, ], ]; } protected function contentForCourierInformation(): array { $filters = $this->courierInformationFilters(request()); $tab = (string) request()->query('courier_tab', 'express'); $tab = in_array($tab, ['express', 'networks', 'accounts', 'logs'], true) ? $tab : 'express'; $page = max((int) request()->query('page', 1), 1); $limit = min(max((int) setting('default.list_limit', 25), 1), 100); $rows = new LengthAwarePaginator([], 0, $limit, $page, [ 'path' => request()->url(), 'query' => request()->except('page'), ]); $error = null; $pending = false; $state = null; $syncStatus = ['code' => 'unavailable', 'lag_minutes' => null]; $relatedAvailability = $this->courierInformation->relatedAvailability(); try { if ($tab === 'express') { if (! $this->courierInformation->available()) { $error = '共享业务镜像尚未建立,快递资料暂时无法读取。'; } else { $state = $this->courierInformation->state(); $syncStatus = $this->courierInformation->syncStatus($state); if (! $this->courierInformation->ready()) { $pending = true; $error = '快递资料正在执行首次全量同步,完成前不会把未同步数据误判为无数据。'; } else { $result = $this->courierInformation->paginate($filters, $page, $limit); $rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page); } } } elseif ($tab === 'networks') { if (! ($relatedAvailability['networks'] ?? false)) { $error = '快递网点镜像尚未建立,请先完成 wp_express_expNet 的首次同步。'; } elseif (! ($relatedAvailability['network_ready'] ?? false)) { $pending = true; $error = '快递网点正在执行首次全量同步,完成前不会把未同步数据误判为无数据。'; } else { $result = $this->courierInformation->paginateNetworks($filters['expcode'] ?: null, $page, $limit); $rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page); } } elseif ($tab === 'accounts') { if (! ($relatedAvailability['accounts'] ?? false)) { $error = '打款账户镜像尚未建立,请先完成 wp_express_account 的首次同步。'; } elseif (! ($relatedAvailability['account_ready'] ?? false)) { $pending = true; $error = '打款账户正在执行首次全量同步,完成前不会把未同步数据误判为无数据。'; } else { $result = $this->courierInformation->paginateAccounts($filters['expcode'] ?: null, $page, $limit); $rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page); } } else { if (! ($relatedAvailability['logs'] ?? false)) { $error = '操作日志镜像尚未建立,请先完成 wp_express_extension 的首次同步。'; } elseif (! ($relatedAvailability['log_ready'] ?? false)) { $pending = true; $error = '操作日志正在执行首次全量同步,完成前不会把未同步数据误判为无数据。'; } else { $result = $this->courierInformation->paginateLogs($filters['expcode'] ?: null, $page, $limit); $rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page); } } } catch (Throwable $exception) { report($exception); $error = '快递资料共享镜像暂时无法读取,请稍后重试。'; } return [ 'view' => 'business.pages.courier-payables.courier-information', 'courierTab' => $tab, 'courierFilters' => $filters, 'courierRows' => $rows, 'courierState' => $state, 'courierSyncStatus' => $syncStatus, 'courierError' => $error, 'courierPending' => $pending, 'courierRelatedAvailability' => $relatedAvailability, ]; } /** @return array */ private function courierInformationFilters(Request $request): array { $filters = [ 'expcode' => mb_substr(trim((string) $request->query('expcode', '')), 0, 64), 'expname' => mb_substr(trim((string) $request->query('expname', '')), 0, 100), 'undefined1' => mb_substr(trim((string) $request->query('undefined1', '')), 0, 100), 'undefined2' => mb_substr(trim((string) $request->query('undefined2', '')), 0, 100), 'undefined3' => mb_substr(trim((string) $request->query('undefined3', '')), 0, 100), 'tradebtime_from' => $this->dateFilter($request->query('tradebtime_from')), 'tradebtime_to' => $this->dateFilter($request->query('tradebtime_to')), 'status' => in_array((string) $request->query('status', ''), ['0', '1'], true) ? (string) $request->query('status') : '', ]; if ($filters['tradebtime_from'] !== '' && $filters['tradebtime_to'] !== '' && $filters['tradebtime_from'] > $filters['tradebtime_to']) { [$filters['tradebtime_from'], $filters['tradebtime_to']] = [ $filters['tradebtime_to'], $filters['tradebtime_from'], ]; } return $filters; } private function courierPaginator(array $rows, int $total, int $limit, int $page): LengthAwarePaginator { return new LengthAwarePaginator( Collection::make($rows), $total, $limit, $page, [ 'path' => request()->url(), 'query' => request()->except('page'), ], ); } 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; } if (in_array($tab, self::PROMOTION_MARKETING_TABS, true)) { return app(PlatformPromotionAccountPageData::class)->content($tab); } // 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 ($this->isStoreReceivingAccountPage($section, $page)) { abort_unless(in_array($tab, ['taobao', 'jd', 'douyin', 'pinduoduo'], true), 404); $allowedPlatforms = TableRegistry::tabValues($section, $page, $tab); if (in_array($tab, ['douyin', 'pinduoduo'], true)) { $data['store_platform'] = $allowedPlatforms[0]; } else { abort_unless(in_array($data['store_platform'] ?? null, $allowedPlatforms, true), 422, '当前页签不支持该平台类型。'); } } elseif ($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'], ]); if ($this->isStoreReceivingAccountPage($section, $page)) { abort_unless(in_array($tab, ['taobao', 'jd', 'douyin', 'pinduoduo'], true), 404); $result = app(StoreReceivingAccountWorkbookImporter::class)->import( $request->file('import'), $tab, (int) (company_id() ?: 1), ); } else { $result = $importer->import($request->file('import'), $descriptor, $section, $page, $tab); } $message = "导入完成:新增 {$result['created']} 条,更新 {$result['updated']} 条。"; if (($result['skipped'] ?? 0) > 0) { $message .= " 跳过 {$result['skipped']} 条空 ERP 店铺名称示例行。"; } return $this->redirectBack($section, $page, $tab)->with('success', $message); } public function storeReceivingAccountTemplate(string $section, string $page, string $tab) { abort_unless($this->isStoreReceivingAccountPage($section, $page), 404); abort_unless(in_array($tab, ['taobao', 'jd', 'douyin', 'pinduoduo'], true), 404); return app(StoreReceivingAccountTemplate::class)->download($tab); } public function fundForecastTemplate(FundForecastTemplate $template): StreamedResponse { return $template->download(); } 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); } }