afterSalesFilters($filters); [$requestPage, $requestLimit] = $this->pageSettings(); $page = max($page ?? $requestPage, 1); $limit = min(max($limit ?? $requestLimit, 1), 100); try { $result = $this->afterSalesRows($filters, $page, $limit); return [ 'orderDataRows' => $this->paginator( (array) ($result['rows'] ?? []), $page, $limit, (bool) ($result['has_more'] ?? false), ), 'orderDataFilters' => $filters, 'orderDataStatus' => $result['status'] ?? [], 'orderDataError' => null, 'orderDataShopOptions' => $this->afterSalesShopOptions(), 'afterSalesWorkOrders' => $includeWorkOrders ? $this->afterSalesWorkOrders($filters) : $this->emptyAfterSalesWorkOrders(), ]; } catch (ServiceUnavailableHttpException $exception) { return $this->failed($filters, $page, $limit, $exception->getMessage()); } catch (Throwable $exception) { report($exception); return $this->failed($filters, $page, $limit, '订单售后费用数据暂时无法读取,请稍后重试。'); } } /** * Return the same normalized filters used by the raw after-sales page. * Exporters and sibling views must use this contract instead of rebuilding * date and text normalization independently. * * @param array|null $input * @return array{from:string,to:string,order_no:string,shop_name:string,shop_names:array,sku_id:string} */ public function afterSalesFilters(?array $input = null): array { $input ??= request()->query(); $shopNames = $this->shopNames($input['shop_name'] ?? $input['shop_names'] ?? null); $from = $this->date($input['from'] ?? null); $to = $this->date($input['to'] ?? null); // 首次进入页面时给一个近 7 天的默认区间;点了“重置”或提交了空日期后, // 明确不加日期条件(页面上的日期框保持为空),由用户自己决定下一步再看什么。 $cleared = ! empty($input['reset']) || array_key_exists('from', $input) || array_key_exists('to', $input); return [ 'from' => $from !== '' ? $from : ($cleared ? '' : now()->subDays(6)->toDateString()), 'to' => $to !== '' ? $to : ($cleared ? '' : now()->toDateString()), 'order_no' => $this->text($input['order_no'] ?? null), // shop_name 保留单值形态,兼容只认单值的调用方;多选统一用 shop_names。 'shop_name' => $shopNames[0] ?? '', 'shop_names' => $shopNames, 'sku_id' => $this->text($input['sku_id'] ?? null), ]; } /** * 店铺筛选支持多选(shop_name[]=A&shop_name[]=B),也兼容原来的单个 shop_name。 * * @return array */ private function shopNames(mixed $value): array { $values = is_array($value) ? $value : [$value]; $names = []; foreach ($values as $item) { if (! is_scalar($item)) { continue; } $name = $this->text($item); if ($name === '') { continue; } $names[$name] = true; // 单次最多 50 个店铺,避免拼出过长的 WHERE。 if (count($names) >= 50) { break; } } return array_keys($names); } /** * 店铺多选候选:订单镜像里出现过的店铺名称(缓存 1 小时,避免每次页面请求都扫表去重)。 * * @return array */ public function afterSalesShopOptions(): array { try { $mirror = $this->mirrorOrders ?? app(OrderDataMirrorRepository::class); $names = Cache::remember( 'order-data.after-sales.shop-options', now()->addHour(), fn (): array => $mirror->shopNames(), ); return is_array($names) ? array_values($names) : []; } catch (Throwable $exception) { report($exception); return []; } } /** * Fetch raw after-sales rows for a page. Both the page and CSV export use * this method so source selection, filters, joins and row normalization stay * identical. Repositories over-fetch one row to determine whether another * page exists; the sentinel is removed before this shared contract returns. * * @return array{rows:array>,has_more:bool,status:array} */ public function afterSalesRows(array $filters, int $page, int $limit): array { $limit = min(max($limit, 1), 100); $mirror = $this->mirrorOrders ?? app(OrderDataMirrorRepository::class); if ($mirror->ready()) { $result = $mirror->afterSales($filters, $page, $limit); $rows = array_values((array) ($result['rows'] ?? [])); $hasMore = count($rows) > $limit; return [ 'rows' => array_slice($rows, 0, $limit), 'has_more' => $hasMore, 'status' => ['X-Caiwu-Data-Status' => 'mirror'], ]; } $cache = $this->caches->forSource(self::SOURCE); $result = $cache->remember( 'order-data.after-sales.list', compact('filters', 'page', 'limit'), fn () => $this->orders->afterSales($filters, $page, $limit), ); $rows = array_values((array) ($result['rows'] ?? [])); return [ 'rows' => array_slice($rows, 0, $limit), 'has_more' => count($rows) > $limit, 'status' => $cache->responseHeaders(), ]; } public function promotionExpenses(): array { $filters = [ 'date' => $this->date(request('date')), 'shop_name' => $this->text(request('shop_name')), 'sku_id' => $this->text(request('sku_id')), ]; [$page, $limit] = $this->pageSettings(); try { $mirror = $this->mirrorOrders ?? app(OrderDataMirrorRepository::class); if ($mirror->promotionReady()) { $result = $mirror->promotionExpenses($filters, $page, $limit); return [ 'orderDataRows' => $this->paginator((array) ($result['rows'] ?? []), $page, $limit), 'orderDataFilters' => array_replace($filters, ['date' => $result['date'] ?? $filters['date']]), 'orderDataStatus' => ['X-Caiwu-Data-Status' => 'mirror'], 'orderDataError' => null, ]; } $cache = $this->caches->forSource(self::SOURCE); $result = $cache->remember( 'order-data.promotion.list', compact('filters', 'page', 'limit'), fn () => $this->orders->promotionExpenses($filters, $page, $limit), ); return [ 'orderDataRows' => $this->paginator((array) ($result['rows'] ?? []), $page, $limit), 'orderDataFilters' => array_replace($filters, ['date' => $result['date'] ?? $filters['date']]), 'orderDataStatus' => $cache->responseHeaders(), 'orderDataError' => null, ]; } catch (ServiceUnavailableHttpException $exception) { return $this->failed($filters, $page, $limit, $exception->getMessage()); } catch (Throwable $exception) { report($exception); return $this->failed($filters, $page, $limit, '商品推广费用数据暂时无法读取,请稍后重试。'); } } private function failed(array $filters, int $page, int $limit, string $message): array { return [ 'orderDataRows' => $this->paginator([], $page, $limit), 'orderDataFilters' => $filters, 'orderDataStatus' => [], 'orderDataError' => $message, 'orderDataShopOptions' => $this->afterSalesShopOptions(), 'afterSalesWorkOrders' => $this->emptyAfterSalesWorkOrders(), ]; } /** @param array $filters */ private function afterSalesWorkOrders(array $filters): array { if ($this->workOrderMirror === null) { return $this->emptyAfterSalesWorkOrders(); } try { return $this->workOrderMirror->paginateAfterSales( max((int) request('work_order_page', 1), 1), 10, $filters, ); } catch (Throwable) { return $this->emptyAfterSalesWorkOrders(); } } /** @return array{data:list>,total:int,page:int,per_page:int,last_page:int,ready:bool,sync_status:array} */ private function emptyAfterSalesWorkOrders(): array { return [ 'data' => [], 'total' => 0, 'page' => max((int) request('work_order_page', 1), 1), 'per_page' => 10, 'last_page' => 1, 'ready' => false, 'sync_status' => [ 'ready' => false, 'code' => 'unavailable', 'last_succeeded_at' => null, 'datasets' => [], ], ]; } private function paginator(array $rows, int $page, int $limit, ?bool $hasMore = null): Paginator { $paginator = new Paginator( Collection::make($rows)->map(static fn (array $row): object => (object) $row), $limit, $page, [ 'path' => request()->url(), 'query' => request()->except('page'), ], ); if ($hasMore !== null) { $paginator->hasMorePagesWhen($hasMore); } return $paginator; } /** @return array{0:int,1:int} */ private function pageSettings(): array { return [ max((int) request('page', 1), 1), min(max((int) setting('default.list_limit', 25), 1), 100), ]; } private function text(mixed $value): string { return mb_substr(trim((string) $value), 0, 100); } private function date(mixed $value): string { $value = trim((string) $value); if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $matches) !== 1) { return ''; } return checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1]) ? $value : ''; } }