['product_cost'], 'product_quantity' => ['product_quantity'], 'product_amount' => ['product_amount'], 'actual_shipped_quantity' => ['actual_shipped_quantity'], 'actual_shipped_amount' => ['actual_shipped_amount'], 'actual_shipped_cost' => ['actual_shipped_cost'], 'merchant_discount' => ['merchant_discount'], 'freight_income' => ['freight_income'], 'payment_amount' => ['payment_amount'], 'platform_discount' => ['platform_discount'], 'refund_amount' => ['pre_refund_amount', 'post_refund_amount'], 'platform_subsidy' => ['pre_platform_subsidy', 'post_platform_subsidy'], 'pre_platform_subsidy' => ['pre_platform_subsidy'], 'pre_application_quantity' => ['pre_application_quantity'], 'pre_application_amount' => ['pre_application_amount'], 'pre_application_cost' => ['pre_application_cost'], 'pre_return_quantity' => ['pre_return_quantity'], 'pre_return_amount' => ['pre_return_amount'], 'pre_return_cost' => ['pre_return_cost'], 'pre_refund_amount' => ['pre_refund_amount'], 'pre_refund_cost' => ['pre_refund_cost'], 'post_platform_subsidy' => ['post_platform_subsidy'], 'post_application_quantity' => ['post_application_quantity'], 'post_application_amount' => ['post_application_amount'], 'post_application_cost' => ['post_application_cost'], 'post_return_quantity' => ['post_return_quantity'], 'post_return_amount' => ['post_return_amount'], 'post_return_cost' => ['post_return_cost'], 'post_refund_amount' => ['post_refund_amount'], 'post_refund_cost' => ['post_refund_cost'], 'defective_quantity' => ['defective_quantity'], ]; private const ITEM_FIELDS = [ 'internal_order_no', 'online_order_no', 'sub_order_no', 'after_sales_no', 'pay_time', 'send_time', 'order_time', 'shop_name', 'product_code', 'style_code', 'shop_product_code', 'shop_style_code', 'product_name', 'color_spec', 'product_quantity', 'product_amount', 'product_cost', 'actual_shipped_quantity', 'actual_shipped_amount', 'actual_shipped_cost', 'merchant_discount', 'freight_income', 'payment_amount', 'platform_discount', 'refund_type', 'pre_application_quantity', 'pre_application_amount', 'pre_application_cost', 'pre_return_quantity', 'pre_return_amount', 'pre_return_cost', 'pre_refund_amount', 'pre_refund_cost', 'pre_platform_subsidy', 'post_application_quantity', 'post_application_amount', 'post_application_cost', 'post_return_quantity', 'post_return_amount', 'post_return_cost', 'post_refund_amount', 'post_refund_cost', 'post_platform_subsidy', 'defective_quantity', 'classification_order', 'combine_type', 'shipping_warehouse', 'express_no', 'express_company', 'order_tags', 'after_sales_tags', 'item_tags', ]; private ?bool $readyCache = null; public function __construct(private readonly ?GoodsProfitAfsRepository $fields = null) { } public function ready(): bool { return $this->readyCache ??= ($this->fields ?? app(GoodsProfitAfsRepository::class))->ready(); } public function status(): array { try { $state = $this->connection()->table('business_mirror_table_states') ->where('dataset_key', GoodsProfitAfsRepository::MAIN_DATASET) ->first(['initial_completed_at', 'last_succeeded_at', 'last_error_code']); } catch (\Throwable) { $state = null; } return [ 'ready' => $this->ready(), 'initial_completed_at' => $state->initial_completed_at ?? null, 'last_succeeded_at' => $state->last_succeeded_at ?? null, 'last_error_code' => $state->last_error_code ?? null, ]; } /** * @param array $filters * @return array{rows:list>,has_more:bool} */ public function paginate(array $filters, int $page, int $limit): array { if (! $this->hasActiveFilters($filters)) { return ['rows' => [], 'has_more' => false]; } $columns = $this->columns(); foreach ([ 'shop_name', 'online_order_no', 'internal_order_no', 'order_time', 'product_cost', 'refund_type', 'post_refund_amount', 'post_platform_subsidy', 'pre_refund_amount', 'pre_platform_subsidy', 'classification_order', ] as $field) { if (! isset($columns[$field])) { return ['rows' => [], 'has_more' => false]; } } $dateField = $this->dateField($filters); if ($this->hasDateRange($filters) && $this->queryDateColumn($columns, $dateField) === null) { return ['rows' => [], 'has_more' => false]; } $page = max($page, 1); $limit = min(max($limit, 1), 100); $orderValues = self::batchValues((string) ($filters['order_no'] ?? '')); $hasMore = null; $selectedGroups = null; if ($orderValues !== []) { $ids = $this->orderNumberIds($orderValues, $columns); if ($ids === []) { return ['rows' => [], 'has_more' => false]; } $query = $this->connection()->table(self::TABLE)->whereIn('_mirror_key', $ids); $query = $this->filtered( $query, array_replace($filters, ['order_no' => '', 'from' => '', 'to' => '']), $columns, ); } else { $filtered = $this->filtered($this->connection()->table(self::TABLE), $filters, $columns); $groups = $this->pageOrderGroups($filtered, $columns, $dateField, $page, $limit); if ($groups === []) { return ['rows' => [], 'has_more' => false]; } $hasMore = count($groups) > $limit; $groups = array_slice($groups, 0, $limit); $query = $this->filtered($this->connection()->table(self::TABLE), $filters, $columns); $this->constrainToOrderGroups($query, $groups, $columns); $selectedGroups = []; foreach ($groups as $group) { $selectedGroups[$group['shop_name'] . "\0" . $group['order_key']] = true; } } $shop = $this->quote($columns['shop_name']); $online = $this->quote($columns['online_order_no']); $internal = $this->quote($columns['internal_order_no']); $orderTime = $this->quote($columns['order_time']); $refundType = $this->quote($columns['refund_type']); $categoryOrder = $this->quote($columns['classification_order']); $orderKey = "COALESCE(NULLIF(TRIM({$online}), ''), TRIM({$internal}))"; $sortDate = $this->quote( $this->queryDateColumn($columns, $dateField) ?? $columns['order_time'], ); $query ->selectRaw("{$shop} AS shop_name") ->selectRaw("{$online} AS online_order_no") ->selectRaw("MAX({$internal}) AS internal_order_no") ->selectRaw("{$orderKey} AS order_key") ->selectRaw("MAX({$orderTime}) AS order_time") ->selectRaw("MAX(NULLIF(TRIM({$refundType}), '')) AS refund_type") ->selectRaw("MAX(NULLIF(TRIM({$categoryOrder}), '')) AS classification_order") ->selectRaw('COUNT(*) AS source_row_count'); foreach (self::SUMMARY_NUMERIC_FIELDS as $alias => $fields) { $query->selectRaw($this->sumExpression($columns, $fields) . ' AS ' . $alias); } $records = $query ->groupByRaw("{$shop}, {$orderKey}") ->orderByRaw("MAX({$sortDate}) DESC") ->orderByRaw("MAX({$internal}) DESC"); if ($selectedGroups === null) { $records = $records->forPage($page, $limit + 1)->get(); } else { $records = $records->get()->filter(static function (object $record) use ($selectedGroups): bool { return isset($selectedGroups[ trim((string) ($record->shop_name ?? '')) . "\0" . trim((string) ($record->order_key ?? '')) ]); })->values(); } $rows = $records->map(static function (object $record): array { $row = [ 'shop_name' => trim((string) ($record->shop_name ?? '')), 'online_order_no' => trim((string) ($record->online_order_no ?? '')), 'internal_order_no' => trim((string) ($record->internal_order_no ?? '')), 'order_key' => trim((string) ($record->order_key ?? '')), 'order_time' => $record->order_time, 'refund_type' => $record->refund_type, 'classification_order' => $record->classification_order, 'source_row_count' => (int) ($record->source_row_count ?? 0), 'matched_package_fee' => null, 'matched_freight_fee' => null, 'matched_bill_fee' => null, 'items' => [], ]; foreach (array_keys(self::SUMMARY_NUMERIC_FIELDS) as $field) { $row[$field] = round((float) ($record->{$field} ?? 0), 2); } return $row; })->all(); if ($hasMore === null) { $hasMore = count($rows) > $limit; } $rows = array_slice($rows, 0, $limit); $this->hydrateItems($rows, $columns); return ['rows' => $rows, 'has_more' => $hasMore]; } /** @return list */ public function shopNames(): array { $cacheKey = 'order-profit.shop-options:undefined8'; $cached = Cache::get($cacheKey); if (is_array($cached)) { return $cached; } if (! $this->ready()) { return []; } $columns = $this->columns(); $shopColumn = $columns['shop_name'] ?? null; if ($shopColumn === null) { return []; } $names = $this->connection()->table(self::TABLE) ->select($shopColumn) ->whereNotNull($shopColumn) ->where($shopColumn, '<>', '') ->distinct() ->orderBy($shopColumn) ->pluck($shopColumn) ->map(static fn (mixed $name): string => trim((string) $name)) ->filter() ->values() ->all(); Cache::put($cacheKey, $names, now()->addHours(24)); return $names; } /** @return array */ private function columns(): array { return ($this->fields ?? app(GoodsProfitAfsRepository::class))->fieldColumns(); } /** * @param list $orderValues * @param array $columns * @return list */ private function orderNumberIds(array $orderValues, array $columns): array { $ids = []; $single = count($orderValues) === 1 ? $orderValues[0] : null; foreach (['online_order_no', 'internal_order_no'] as $field) { if (! isset($columns[$field])) { continue; } if ($field === 'internal_order_no') { $orderValues = array_values(array_filter( $orderValues, static fn (string $value): bool => mb_strlen($value) <= 11, )); if ($orderValues === []) { continue; } } $query = $this->connection()->table(self::TABLE)->select('_mirror_key')->limit(10000); if ($single !== null) { $query->where($columns[$field], 'like', $single . '%'); } else { $query->whereIn($columns[$field], $orderValues); } foreach ($query->pluck('_mirror_key') as $id) { $ids[(string) $id] = true; } } return array_keys($ids); } /** * 先按日期/筛选条件只取当前页的订单键,再对少量订单做宽表汇总。 * 这样近 7 天未指定店铺时不会对整段宽表逐行汇总后再丢弃分页外数据。 * * @param array $columns * @return list */ private function pageOrderGroups( Builder $query, array $columns, string $dateField, int $page, int $pageSize, ): array { $shop = $this->quote($columns['shop_name']); $online = $this->quote($columns['online_order_no']); $internal = $this->quote($columns['internal_order_no']); $orderKey = "COALESCE(NULLIF(TRIM({$online}), ''), TRIM({$internal}))"; $sortDate = $this->quote( $this->queryDateColumn($columns, $dateField) ?? $columns['order_time'], ); return $query ->selectRaw("{$shop} AS shop_name") ->selectRaw("{$online} AS online_order_no") ->selectRaw("MAX({$internal}) AS internal_order_no") ->selectRaw("{$orderKey} AS order_key") ->groupByRaw("{$shop}, {$orderKey}") ->orderByRaw("MAX({$sortDate}) DESC") ->orderByRaw("MAX({$internal}) DESC") ->offset(($page - 1) * $pageSize) ->limit($pageSize + 1) ->get() ->map(static fn (object $row): array => [ 'shop_name' => trim((string) ($row->shop_name ?? '')), 'online_order_no' => trim((string) ($row->online_order_no ?? '')), 'internal_order_no' => trim((string) ($row->internal_order_no ?? '')), 'order_key' => trim((string) ($row->order_key ?? '')), ]) ->all(); } /** * @param list $groups * @param array $columns */ private function constrainToOrderGroups(Builder $query, array $groups, array $columns): void { $onlineOrders = []; $internalOrders = []; $shops = []; foreach ($groups as $group) { $shops[$group['shop_name']] = true; if ($group['online_order_no'] !== '') { $onlineOrders[$group['online_order_no']] = true; } elseif ($group['internal_order_no'] !== '') { $internalOrders[$group['internal_order_no']] = true; } } $query->where(function (Builder $orders) use ($columns, $onlineOrders, $internalOrders): void { if ($onlineOrders !== []) { $orders->whereIn($columns['online_order_no'], array_keys($onlineOrders)); } if ($internalOrders !== []) { $method = $onlineOrders === [] ? 'whereIn' : 'orWhereIn'; $orders->{$method}($columns['internal_order_no'], array_keys($internalOrders)); } }); $query->whereIn($columns['shop_name'], array_keys($shops)); } /** * @param array $filters * @param array $columns */ private function filtered(Builder $query, array $filters, array $columns): Builder { $dateColumn = $this->queryDateColumn($columns, $this->dateField($filters)); $from = trim((string) ($filters['from'] ?? '')); $to = trim((string) ($filters['to'] ?? '')); if ($dateColumn !== null && $from !== '') { $query->where($dateColumn, '>=', $from . ' 00:00:00'); } if ($dateColumn !== null && $to !== '') { $query->where($dateColumn, '<=', $to . ' 23:59:59'); } $shops = self::batchValues((string) ($filters['shop_name'] ?? '')); if ($shops !== [] && isset($columns['shop_name'])) { $query->whereIn($columns['shop_name'], $shops); } $orderValues = self::batchValues((string) ($filters['order_no'] ?? '')); if ($orderValues !== []) { $query->where(function (Builder $order) use ($orderValues, $columns): void { foreach (['online_order_no', 'internal_order_no'] as $field) { if (! isset($columns[$field])) { continue; } $order->orWhereIn($columns[$field], $orderValues); } }); } $skuValues = self::batchValues((string) ($filters['sku_id'] ?? '')); if ($skuValues !== [] && isset($columns['product_code'])) { $query->whereIn($columns['product_code'], $skuValues); } return $query; } /** * @param list> $rows * @param array $columns */ private function hydrateItems(array &$rows, array $columns): void { if ($rows === []) { return; } $itemFields = array_values(array_filter( self::ITEM_FIELDS, static fn (string $field): bool => isset($columns[$field]), )); if ($itemFields === []) { return; } $onlineValues = array_values(array_filter(array_column($rows, 'online_order_no'))); $internalValues = array_values(array_filter(array_column($rows, 'internal_order_no'))); if ($onlineValues === [] && $internalValues === []) { return; } $query = $this->connection()->table(self::TABLE); $query->where(function (Builder $orders) use ($columns, $onlineValues, $internalValues): void { if ($onlineValues !== [] && isset($columns['online_order_no'])) { $orders->whereIn($columns['online_order_no'], $onlineValues); } if ($internalValues !== [] && isset($columns['internal_order_no'])) { $method = $onlineValues === [] ? 'whereIn' : 'orWhereIn'; $orders->{$method}($columns['internal_order_no'], $internalValues); } }); $select = []; foreach ($itemFields as $field) { $select[] = $columns[$field] . ' AS ' . $field; } $itemsByOrder = []; foreach ($query->get($select) as $record) { $item = []; foreach ($itemFields as $field) { $value = $record->{$field} ?? null; $item[$field] = in_array($field, self::NUMERIC_FIELDS, true) ? ($value === null || $value === '' ? null : (float) $value) : $value; } $online = trim((string) ($item['online_order_no'] ?? '')); $internal = trim((string) ($item['internal_order_no'] ?? '')); $orderKey = $online !== '' ? $online : $internal; $itemsByOrder[$orderKey][] = $item; } foreach ($rows as &$row) { $key = trim((string) ($row['order_key'] ?? '')); $row['items'] = $itemsByOrder[$key] ?? []; } unset($row); } /** @param list $fields */ private function sumExpression(array $columns, array $fields): string { $parts = []; foreach ($fields as $field) { if (isset($columns[$field])) { $parts[] = 'COALESCE(CAST(NULLIF(TRIM(' . $this->quote($columns[$field]) . "), '') AS DECIMAL(18,2)), 0)"; } } return $parts === [] ? '0' : 'SUM(' . implode(' + ', $parts) . ')'; } private function queryDateColumn(array $columns, string $dateField): ?string { $definition = GoodsProfitAfsLookupSchema::DATE_COLUMNS[$dateField] ?? null; if ($definition === null) { return null; } if (($columns[$dateField] ?? null) === $definition['source'] && Schema::connection($this->connectionName())->hasColumn(self::TABLE, $definition['lookup'])) { return $definition['lookup']; } return $columns[$dateField] ?? null; } private function connection(): ConnectionInterface { return DB::connection($this->connectionName()); } private function connectionName(): string { return (string) config('upstream_mirrors.connection', 'business_mirror'); } private function quote(string $column): string { if (preg_match('/^[A-Za-z0-9_]+$/', $column) !== 1) { throw new \InvalidArgumentException('订单利润字段名不安全。'); } return '`' . $column . '`'; } /** @param array $filters */ private function hasActiveFilters(array $filters): bool { foreach (['from', 'to', 'order_no', 'shop_name', 'sku_id'] as $key) { if (trim((string) ($filters[$key] ?? '')) !== '') { return true; } } return false; } /** @param array $filters */ private function dateField(array $filters): string { $dateField = trim((string) ($filters['date_field'] ?? 'pay_time')); return isset(GoodsProfitAfsLookupSchema::DATE_COLUMNS[$dateField]) ? $dateField : 'pay_time'; } /** @param array $filters */ private function hasDateRange(array $filters): bool { return trim((string) ($filters['from'] ?? '')) !== '' || trim((string) ($filters['to'] ?? '')) !== ''; } /** @return list */ private static function batchValues(string $value): array { $parts = preg_split('/[\s,,;;]+/u', trim($value)) ?: []; $values = []; foreach ($parts as $part) { $part = trim($part); if ($part !== '') { $values[$part] = true; } if (count($values) >= 100) { break; } } return array_keys($values); } }